From cca4403ab42cf1a1a6f8d4d30c5925c368d6c382 Mon Sep 17 00:00:00 2001 From: Sandrine Pataut Date: Thu, 7 Aug 2025 12:46:53 +0200 Subject: [PATCH 001/116] Refactor tests to remove the embedded git (#32) * refactor tests to remove the embedded git * add a blank file to commit the data folder * remove reset from commit test * test with .gitconfig * edit conftest * use monkeypatch in conftest * small fix * try with os * new try with os * try with monkeypatch and committer * try with monkeypatch with author and committer * address review comments --- src/subcommand/commit_subcommand.cpp | 2 +- test/conftest.py | 22 ++- test/data/blank_file.txt | 0 .../status_data/embedded_git/COMMIT_EDITMSG | 1 - test/data/status_data/embedded_git/HEAD | 1 - test/data/status_data/embedded_git/config | 5 - .../data/status_data/embedded_git/description | 1 - .../embedded_git/hooks/applypatch-msg.sample | 15 -- .../embedded_git/hooks/commit-msg.sample | 24 --- .../hooks/fsmonitor-watchman.sample | 174 ------------------ .../embedded_git/hooks/post-update.sample | 8 - .../embedded_git/hooks/pre-applypatch.sample | 14 -- .../embedded_git/hooks/pre-commit.sample | 49 ----- .../hooks/pre-merge-commit.sample | 13 -- .../embedded_git/hooks/pre-push.sample | 53 ------ .../embedded_git/hooks/pre-rebase.sample | 169 ----------------- .../embedded_git/hooks/pre-receive.sample | 24 --- .../hooks/prepare-commit-msg.sample | 42 ----- .../hooks/push-to-checkout.sample | 78 -------- .../hooks/sendemail-validate.sample | 77 -------- .../embedded_git/hooks/update.sample | 128 ------------- test/data/status_data/embedded_git/index | Bin 294 -> 0 bytes .../status_data/embedded_git/info/exclude | 6 - test/data/status_data/embedded_git/logs/HEAD | 18 -- .../logs/refs/heads/commit_test_branch | 1 - .../embedded_git/logs/refs/heads/main | 2 - .../75/743dcbd85064226c77a0b862af817838ae0b2e | Bin 132 -> 0 bytes .../9c/a9a8716bf7f5ab1ff449be0c97a04a7aae4262 | Bin 38 -> 0 bytes .../a3/079a98067bc027a22dc56fe619c5d30f23d3a6 | Bin 129 -> 0 bytes .../bd/ef436c07ee899d19659581d2944f573ac620e0 | Bin 45 -> 0 bytes .../cb/a545ef5cc4ddf12a9744b6a49b20dda1ef1d5c | Bin 175 -> 0 bytes .../dc/430e3c2e6e1193b377040126b95099261e7251 | Bin 38 -> 0 bytes .../e3/323b422ccebe9515b4aa335b40615f398b6553 | Bin 121 -> 0 bytes .../ee/8c4cf874c4f1e3ba755f929fe7811018adee3d | 2 - .../fd/511fbb5dd2860baabf28e298fa3373634e8660 | Bin 89 -> 0 bytes .../refs/heads/commit_test_branch | 1 - .../status_data/embedded_git/refs/heads/main | 1 - test/data/status_data/last_test_file.txt | 1 - test/data/status_data/other_test_file.txt | 1 - test/data/status_data/random_file.txt | 1 - test/data/status_data/test_file.txt | 1 - test/test_add.py | 20 +- test/test_branch.py | 27 +-- test/test_checkout.py | 35 ++-- test/test_clone.py | 2 +- test/test_commit.py | 27 +-- test/test_status.py | 53 ++++-- 47 files changed, 116 insertions(+), 983 deletions(-) create mode 100644 test/data/blank_file.txt delete mode 100644 test/data/status_data/embedded_git/COMMIT_EDITMSG delete mode 100644 test/data/status_data/embedded_git/HEAD delete mode 100755 test/data/status_data/embedded_git/config delete mode 100644 test/data/status_data/embedded_git/description delete mode 100755 test/data/status_data/embedded_git/hooks/applypatch-msg.sample delete mode 100755 test/data/status_data/embedded_git/hooks/commit-msg.sample delete mode 100755 test/data/status_data/embedded_git/hooks/fsmonitor-watchman.sample delete mode 100755 test/data/status_data/embedded_git/hooks/post-update.sample delete mode 100755 test/data/status_data/embedded_git/hooks/pre-applypatch.sample delete mode 100755 test/data/status_data/embedded_git/hooks/pre-commit.sample delete mode 100755 test/data/status_data/embedded_git/hooks/pre-merge-commit.sample delete mode 100755 test/data/status_data/embedded_git/hooks/pre-push.sample delete mode 100755 test/data/status_data/embedded_git/hooks/pre-rebase.sample delete mode 100755 test/data/status_data/embedded_git/hooks/pre-receive.sample delete mode 100755 test/data/status_data/embedded_git/hooks/prepare-commit-msg.sample delete mode 100755 test/data/status_data/embedded_git/hooks/push-to-checkout.sample delete mode 100755 test/data/status_data/embedded_git/hooks/sendemail-validate.sample delete mode 100755 test/data/status_data/embedded_git/hooks/update.sample delete mode 100644 test/data/status_data/embedded_git/index delete mode 100644 test/data/status_data/embedded_git/info/exclude delete mode 100644 test/data/status_data/embedded_git/logs/HEAD delete mode 100644 test/data/status_data/embedded_git/logs/refs/heads/commit_test_branch delete mode 100644 test/data/status_data/embedded_git/logs/refs/heads/main delete mode 100644 test/data/status_data/embedded_git/objects/75/743dcbd85064226c77a0b862af817838ae0b2e delete mode 100644 test/data/status_data/embedded_git/objects/9c/a9a8716bf7f5ab1ff449be0c97a04a7aae4262 delete mode 100644 test/data/status_data/embedded_git/objects/a3/079a98067bc027a22dc56fe619c5d30f23d3a6 delete mode 100644 test/data/status_data/embedded_git/objects/bd/ef436c07ee899d19659581d2944f573ac620e0 delete mode 100644 test/data/status_data/embedded_git/objects/cb/a545ef5cc4ddf12a9744b6a49b20dda1ef1d5c delete mode 100644 test/data/status_data/embedded_git/objects/dc/430e3c2e6e1193b377040126b95099261e7251 delete mode 100644 test/data/status_data/embedded_git/objects/e3/323b422ccebe9515b4aa335b40615f398b6553 delete mode 100644 test/data/status_data/embedded_git/objects/ee/8c4cf874c4f1e3ba755f929fe7811018adee3d delete mode 100644 test/data/status_data/embedded_git/objects/fd/511fbb5dd2860baabf28e298fa3373634e8660 delete mode 100644 test/data/status_data/embedded_git/refs/heads/commit_test_branch delete mode 100644 test/data/status_data/embedded_git/refs/heads/main delete mode 100644 test/data/status_data/last_test_file.txt delete mode 100644 test/data/status_data/other_test_file.txt delete mode 100644 test/data/status_data/random_file.txt delete mode 100644 test/data/status_data/test_file.txt diff --git a/src/subcommand/commit_subcommand.cpp b/src/subcommand/commit_subcommand.cpp index b476100..e70f424 100644 --- a/src/subcommand/commit_subcommand.cpp +++ b/src/subcommand/commit_subcommand.cpp @@ -1,7 +1,7 @@ #include #include -#include "commit_subcommand.hpp" +#include "../subcommand/commit_subcommand.hpp" #include "../wrapper/index_wrapper.hpp" #include "../wrapper/repository_wrapper.hpp" diff --git a/test/conftest.py b/test/conftest.py index 75c398e..b84b04a 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -1,6 +1,8 @@ import os from pathlib import Path import pytest +import subprocess + # Fixture to run test in current tmp_path @pytest.fixture @@ -15,7 +17,21 @@ def git2cpp_path(): return Path(__file__).parent.parent / 'build' / 'git2cpp' @pytest.fixture -def rename_git(): - os.rename("test/data/status_data/embedded_git/", "test/data/status_data/.git/") +def xtl_clone(git2cpp_path): + url = 'https://github.com/xtensor-stack/xtl.git' + clone_working_dir = 'test/data' + + clone_cmd = [git2cpp_path, 'clone', url] + subprocess.run(clone_cmd, capture_output=True, cwd = clone_working_dir, text=True) + yield - os.rename("test/data/status_data/.git/", "test/data/status_data/embedded_git/") + + cleanup_cmd = ['rm', '-rf', 'xtl'] + subprocess.run(cleanup_cmd, capture_output=True, cwd = clone_working_dir, text=True) + +@pytest.fixture +def git_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") diff --git a/test/data/blank_file.txt b/test/data/blank_file.txt new file mode 100644 index 0000000..e69de29 diff --git a/test/data/status_data/embedded_git/COMMIT_EDITMSG b/test/data/status_data/embedded_git/COMMIT_EDITMSG deleted file mode 100644 index c133ee6..0000000 --- a/test/data/status_data/embedded_git/COMMIT_EDITMSG +++ /dev/null @@ -1 +0,0 @@ -Second commit diff --git a/test/data/status_data/embedded_git/HEAD b/test/data/status_data/embedded_git/HEAD deleted file mode 100644 index 990e32d..0000000 --- a/test/data/status_data/embedded_git/HEAD +++ /dev/null @@ -1 +0,0 @@ -ref: refs/heads/commit_test_branch diff --git a/test/data/status_data/embedded_git/config b/test/data/status_data/embedded_git/config deleted file mode 100755 index 515f483..0000000 --- a/test/data/status_data/embedded_git/config +++ /dev/null @@ -1,5 +0,0 @@ -[core] - repositoryformatversion = 0 - filemode = true - bare = false - logallrefupdates = true diff --git a/test/data/status_data/embedded_git/description b/test/data/status_data/embedded_git/description deleted file mode 100644 index 498b267..0000000 --- a/test/data/status_data/embedded_git/description +++ /dev/null @@ -1 +0,0 @@ -Unnamed repository; edit this file 'description' to name the repository. diff --git a/test/data/status_data/embedded_git/hooks/applypatch-msg.sample b/test/data/status_data/embedded_git/hooks/applypatch-msg.sample deleted file mode 100755 index 09b38a8..0000000 --- a/test/data/status_data/embedded_git/hooks/applypatch-msg.sample +++ /dev/null @@ -1,15 +0,0 @@ -#!/usr/bin/sh -# -# An example hook script to check the commit log message taken by -# applypatch from an e-mail message. -# -# The hook should exit with non-zero status after issuing an -# appropriate message if it wants to stop the commit. The hook is -# allowed to edit the commit message file. -# -# To enable this hook, rename this file to "applypatch-msg". - -. git-sh-setup -commitmsg="$(git rev-parse --git-path hooks/commit-msg)" -test -x "$commitmsg" && exec "$commitmsg" ${1+"$@"} -: diff --git a/test/data/status_data/embedded_git/hooks/commit-msg.sample b/test/data/status_data/embedded_git/hooks/commit-msg.sample deleted file mode 100755 index 3ced146..0000000 --- a/test/data/status_data/embedded_git/hooks/commit-msg.sample +++ /dev/null @@ -1,24 +0,0 @@ -#!/usr/bin/sh -# -# An example hook script to check the commit log message. -# Called by "git commit" with one argument, the name of the file -# that has the commit message. The hook should exit with non-zero -# status after issuing an appropriate message if it wants to stop the -# commit. The hook is allowed to edit the commit message file. -# -# To enable this hook, rename this file to "commit-msg". - -# Uncomment the below to add a Signed-off-by line to the message. -# Doing this in a hook is a bad idea in general, but the prepare-commit-msg -# hook is more suited to it. -# -# SOB=$(git var GIT_AUTHOR_IDENT | sed -n 's/^\(.*>\).*$/Signed-off-by: \1/p') -# grep -qs "^$SOB" "$1" || echo "$SOB" >> "$1" - -# This example catches duplicate Signed-off-by lines. - -test "" = "$(grep '^Signed-off-by: ' "$1" | - sort | uniq -c | sed -e '/^[ ]*1[ ]/d')" || { - echo >&2 Duplicate Signed-off-by lines. - exit 1 -} diff --git a/test/data/status_data/embedded_git/hooks/fsmonitor-watchman.sample b/test/data/status_data/embedded_git/hooks/fsmonitor-watchman.sample deleted file mode 100755 index 23e856f..0000000 --- a/test/data/status_data/embedded_git/hooks/fsmonitor-watchman.sample +++ /dev/null @@ -1,174 +0,0 @@ -#!/usr/bin/perl - -use strict; -use warnings; -use IPC::Open2; - -# An example hook script to integrate Watchman -# (https://facebook.github.io/watchman/) with git to speed up detecting -# new and modified files. -# -# The hook is passed a version (currently 2) and last update token -# formatted as a string and outputs to stdout a new update token and -# all files that have been modified since the update token. Paths must -# be relative to the root of the working tree and separated by a single NUL. -# -# To enable this hook, rename this file to "query-watchman" and set -# 'git config core.fsmonitor .git/hooks/query-watchman' -# -my ($version, $last_update_token) = @ARGV; - -# Uncomment for debugging -# print STDERR "$0 $version $last_update_token\n"; - -# Check the hook interface version -if ($version ne 2) { - die "Unsupported query-fsmonitor hook version '$version'.\n" . - "Falling back to scanning...\n"; -} - -my $git_work_tree = get_working_dir(); - -my $retry = 1; - -my $json_pkg; -eval { - require JSON::XS; - $json_pkg = "JSON::XS"; - 1; -} or do { - require JSON::PP; - $json_pkg = "JSON::PP"; -}; - -launch_watchman(); - -sub launch_watchman { - my $o = watchman_query(); - if (is_work_tree_watched($o)) { - output_result($o->{clock}, @{$o->{files}}); - } -} - -sub output_result { - my ($clockid, @files) = @_; - - # Uncomment for debugging watchman output - # open (my $fh, ">", ".git/watchman-output.out"); - # binmode $fh, ":utf8"; - # print $fh "$clockid\n@files\n"; - # close $fh; - - binmode STDOUT, ":utf8"; - print $clockid; - print "\0"; - local $, = "\0"; - print @files; -} - -sub watchman_clock { - my $response = qx/watchman clock "$git_work_tree"/; - die "Failed to get clock id on '$git_work_tree'.\n" . - "Falling back to scanning...\n" if $? != 0; - - return $json_pkg->new->utf8->decode($response); -} - -sub watchman_query { - my $pid = open2(\*CHLD_OUT, \*CHLD_IN, 'watchman -j --no-pretty') - or die "open2() failed: $!\n" . - "Falling back to scanning...\n"; - - # In the query expression below we're asking for names of files that - # changed since $last_update_token but not from the .git folder. - # - # To accomplish this, we're using the "since" generator to use the - # recency index to select candidate nodes and "fields" to limit the - # output to file names only. Then we're using the "expression" term to - # further constrain the results. - my $last_update_line = ""; - if (substr($last_update_token, 0, 1) eq "c") { - $last_update_token = "\"$last_update_token\""; - $last_update_line = qq[\n"since": $last_update_token,]; - } - my $query = <<" END"; - ["query", "$git_work_tree", {$last_update_line - "fields": ["name"], - "expression": ["not", ["dirname", ".git"]] - }] - END - - # Uncomment for debugging the watchman query - # open (my $fh, ">", ".git/watchman-query.json"); - # print $fh $query; - # close $fh; - - print CHLD_IN $query; - close CHLD_IN; - my $response = do {local $/; }; - - # Uncomment for debugging the watch response - # open ($fh, ">", ".git/watchman-response.json"); - # print $fh $response; - # close $fh; - - die "Watchman: command returned no output.\n" . - "Falling back to scanning...\n" if $response eq ""; - die "Watchman: command returned invalid output: $response\n" . - "Falling back to scanning...\n" unless $response =~ /^\{/; - - return $json_pkg->new->utf8->decode($response); -} - -sub is_work_tree_watched { - my ($output) = @_; - my $error = $output->{error}; - if ($retry > 0 and $error and $error =~ m/unable to resolve root .* directory (.*) is not watched/) { - $retry--; - my $response = qx/watchman watch "$git_work_tree"/; - die "Failed to make watchman watch '$git_work_tree'.\n" . - "Falling back to scanning...\n" if $? != 0; - $output = $json_pkg->new->utf8->decode($response); - $error = $output->{error}; - die "Watchman: $error.\n" . - "Falling back to scanning...\n" if $error; - - # Uncomment for debugging watchman output - # open (my $fh, ">", ".git/watchman-output.out"); - # close $fh; - - # Watchman will always return all files on the first query so - # return the fast "everything is dirty" flag to git and do the - # Watchman query just to get it over with now so we won't pay - # the cost in git to look up each individual file. - my $o = watchman_clock(); - $error = $output->{error}; - - die "Watchman: $error.\n" . - "Falling back to scanning...\n" if $error; - - output_result($o->{clock}, ("/")); - $last_update_token = $o->{clock}; - - eval { launch_watchman() }; - return 0; - } - - die "Watchman: $error.\n" . - "Falling back to scanning...\n" if $error; - - return 1; -} - -sub get_working_dir { - my $working_dir; - if ($^O =~ 'msys' || $^O =~ 'cygwin') { - $working_dir = Win32::GetCwd(); - $working_dir =~ tr/\\/\//; - } else { - require Cwd; - $working_dir = Cwd::cwd(); - } - - return $working_dir; -} diff --git a/test/data/status_data/embedded_git/hooks/post-update.sample b/test/data/status_data/embedded_git/hooks/post-update.sample deleted file mode 100755 index ee496e3..0000000 --- a/test/data/status_data/embedded_git/hooks/post-update.sample +++ /dev/null @@ -1,8 +0,0 @@ -#!/usr/bin/sh -# -# An example hook script to prepare a packed repository for use over -# dumb transports. -# -# To enable this hook, rename this file to "post-update". - -exec git update-server-info diff --git a/test/data/status_data/embedded_git/hooks/pre-applypatch.sample b/test/data/status_data/embedded_git/hooks/pre-applypatch.sample deleted file mode 100755 index 845130a..0000000 --- a/test/data/status_data/embedded_git/hooks/pre-applypatch.sample +++ /dev/null @@ -1,14 +0,0 @@ -#!/usr/bin/sh -# -# An example hook script to verify what is about to be committed -# by applypatch from an e-mail message. -# -# The hook should exit with non-zero status after issuing an -# appropriate message if it wants to stop the commit. -# -# To enable this hook, rename this file to "pre-applypatch". - -. git-sh-setup -precommit="$(git rev-parse --git-path hooks/pre-commit)" -test -x "$precommit" && exec "$precommit" ${1+"$@"} -: diff --git a/test/data/status_data/embedded_git/hooks/pre-commit.sample b/test/data/status_data/embedded_git/hooks/pre-commit.sample deleted file mode 100755 index 44f7de3..0000000 --- a/test/data/status_data/embedded_git/hooks/pre-commit.sample +++ /dev/null @@ -1,49 +0,0 @@ -#!/usr/bin/sh -# -# An example hook script to verify what is about to be committed. -# Called by "git commit" with no arguments. The hook should -# exit with non-zero status after issuing an appropriate message if -# it wants to stop the commit. -# -# To enable this hook, rename this file to "pre-commit". - -if git rev-parse --verify HEAD >/dev/null 2>&1 -then - against=HEAD -else - # Initial commit: diff against an empty tree object - against=$(git hash-object -t tree /dev/null) -fi - -# If you want to allow non-ASCII filenames set this variable to true. -allownonascii=$(git config --type=bool hooks.allownonascii) - -# Redirect output to stderr. -exec 1>&2 - -# Cross platform projects tend to avoid non-ASCII filenames; prevent -# them from being added to the repository. We exploit the fact that the -# printable range starts at the space character and ends with tilde. -if [ "$allownonascii" != "true" ] && - # Note that the use of brackets around a tr range is ok here, (it's - # even required, for portability to Solaris 10's /usr/bin/tr), since - # the square bracket bytes happen to fall in the designated range. - test $(git diff-index --cached --name-only --diff-filter=A -z $against | - LC_ALL=C tr -d '[ -~]\0' | wc -c) != 0 -then - cat <<\EOF -Error: Attempt to add a non-ASCII file name. - -This can cause problems if you want to work with people on other platforms. - -To be portable it is advisable to rename the file. - -If you know what you are doing you can disable this check using: - - git config hooks.allownonascii true -EOF - exit 1 -fi - -# If there are whitespace errors, print the offending file names and fail. -exec git diff-index --check --cached $against -- diff --git a/test/data/status_data/embedded_git/hooks/pre-merge-commit.sample b/test/data/status_data/embedded_git/hooks/pre-merge-commit.sample deleted file mode 100755 index 09b65a5..0000000 --- a/test/data/status_data/embedded_git/hooks/pre-merge-commit.sample +++ /dev/null @@ -1,13 +0,0 @@ -#!/usr/bin/sh -# -# An example hook script to verify what is about to be committed. -# Called by "git merge" with no arguments. The hook should -# exit with non-zero status after issuing an appropriate message to -# stderr if it wants to stop the merge commit. -# -# To enable this hook, rename this file to "pre-merge-commit". - -. git-sh-setup -test -x "$GIT_DIR/hooks/pre-commit" && - exec "$GIT_DIR/hooks/pre-commit" -: diff --git a/test/data/status_data/embedded_git/hooks/pre-push.sample b/test/data/status_data/embedded_git/hooks/pre-push.sample deleted file mode 100755 index d5e513d..0000000 --- a/test/data/status_data/embedded_git/hooks/pre-push.sample +++ /dev/null @@ -1,53 +0,0 @@ -#!/usr/bin/sh - -# An example hook script to verify what is about to be pushed. Called by "git -# push" after it has checked the remote status, but before anything has been -# pushed. If this script exits with a non-zero status nothing will be pushed. -# -# This hook is called with the following parameters: -# -# $1 -- Name of the remote to which the push is being done -# $2 -- URL to which the push is being done -# -# If pushing without using a named remote those arguments will be equal. -# -# Information about the commits which are being pushed is supplied as lines to -# the standard input in the form: -# -# -# -# This sample shows how to prevent push of commits where the log message starts -# with "WIP" (work in progress). - -remote="$1" -url="$2" - -zero=$(git hash-object --stdin &2 "Found WIP commit in $local_ref, not pushing" - exit 1 - fi - fi -done - -exit 0 diff --git a/test/data/status_data/embedded_git/hooks/pre-rebase.sample b/test/data/status_data/embedded_git/hooks/pre-rebase.sample deleted file mode 100755 index 36efc64..0000000 --- a/test/data/status_data/embedded_git/hooks/pre-rebase.sample +++ /dev/null @@ -1,169 +0,0 @@ -#!/usr/bin/sh -# -# Copyright (c) 2006, 2008 Junio C Hamano -# -# The "pre-rebase" hook is run just before "git rebase" starts doing -# its job, and can prevent the command from running by exiting with -# non-zero status. -# -# The hook is called with the following parameters: -# -# $1 -- the upstream the series was forked from. -# $2 -- the branch being rebased (or empty when rebasing the current branch). -# -# This sample shows how to prevent topic branches that are already -# merged to 'next' branch from getting rebased, because allowing it -# would result in rebasing already published history. - -publish=next -basebranch="$1" -if test "$#" = 2 -then - topic="refs/heads/$2" -else - topic=`git symbolic-ref HEAD` || - exit 0 ;# we do not interrupt rebasing detached HEAD -fi - -case "$topic" in -refs/heads/??/*) - ;; -*) - exit 0 ;# we do not interrupt others. - ;; -esac - -# Now we are dealing with a topic branch being rebased -# on top of master. Is it OK to rebase it? - -# Does the topic really exist? -git show-ref -q "$topic" || { - echo >&2 "No such branch $topic" - exit 1 -} - -# Is topic fully merged to master? -not_in_master=`git rev-list --pretty=oneline ^master "$topic"` -if test -z "$not_in_master" -then - echo >&2 "$topic is fully merged to master; better remove it." - exit 1 ;# we could allow it, but there is no point. -fi - -# Is topic ever merged to next? If so you should not be rebasing it. -only_next_1=`git rev-list ^master "^$topic" ${publish} | sort` -only_next_2=`git rev-list ^master ${publish} | sort` -if test "$only_next_1" = "$only_next_2" -then - not_in_topic=`git rev-list "^$topic" master` - if test -z "$not_in_topic" - then - echo >&2 "$topic is already up to date with master" - exit 1 ;# we could allow it, but there is no point. - else - exit 0 - fi -else - not_in_next=`git rev-list --pretty=oneline ^${publish} "$topic"` - /usr/bin/perl -e ' - my $topic = $ARGV[0]; - my $msg = "* $topic has commits already merged to public branch:\n"; - my (%not_in_next) = map { - /^([0-9a-f]+) /; - ($1 => 1); - } split(/\n/, $ARGV[1]); - for my $elem (map { - /^([0-9a-f]+) (.*)$/; - [$1 => $2]; - } split(/\n/, $ARGV[2])) { - if (!exists $not_in_next{$elem->[0]}) { - if ($msg) { - print STDERR $msg; - undef $msg; - } - print STDERR " $elem->[1]\n"; - } - } - ' "$topic" "$not_in_next" "$not_in_master" - exit 1 -fi - -<<\DOC_END - -This sample hook safeguards topic branches that have been -published from being rewound. - -The workflow assumed here is: - - * Once a topic branch forks from "master", "master" is never - merged into it again (either directly or indirectly). - - * Once a topic branch is fully cooked and merged into "master", - it is deleted. If you need to build on top of it to correct - earlier mistakes, a new topic branch is created by forking at - the tip of the "master". This is not strictly necessary, but - it makes it easier to keep your history simple. - - * Whenever you need to test or publish your changes to topic - branches, merge them into "next" branch. - -The script, being an example, hardcodes the publish branch name -to be "next", but it is trivial to make it configurable via -$GIT_DIR/config mechanism. - -With this workflow, you would want to know: - -(1) ... if a topic branch has ever been merged to "next". Young - topic branches can have stupid mistakes you would rather - clean up before publishing, and things that have not been - merged into other branches can be easily rebased without - affecting other people. But once it is published, you would - not want to rewind it. - -(2) ... if a topic branch has been fully merged to "master". - Then you can delete it. More importantly, you should not - build on top of it -- other people may already want to - change things related to the topic as patches against your - "master", so if you need further changes, it is better to - fork the topic (perhaps with the same name) afresh from the - tip of "master". - -Let's look at this example: - - o---o---o---o---o---o---o---o---o---o "next" - / / / / - / a---a---b A / / - / / / / - / / c---c---c---c B / - / / / \ / - / / / b---b C \ / - / / / / \ / - ---o---o---o---o---o---o---o---o---o---o---o "master" - - -A, B and C are topic branches. - - * A has one fix since it was merged up to "next". - - * B has finished. It has been fully merged up to "master" and "next", - and is ready to be deleted. - - * C has not merged to "next" at all. - -We would want to allow C to be rebased, refuse A, and encourage -B to be deleted. - -To compute (1): - - git rev-list ^master ^topic next - git rev-list ^master next - - if these match, topic has not merged in next at all. - -To compute (2): - - git rev-list master..topic - - if this is empty, it is fully merged to "master". - -DOC_END diff --git a/test/data/status_data/embedded_git/hooks/pre-receive.sample b/test/data/status_data/embedded_git/hooks/pre-receive.sample deleted file mode 100755 index 6f473c3..0000000 --- a/test/data/status_data/embedded_git/hooks/pre-receive.sample +++ /dev/null @@ -1,24 +0,0 @@ -#!/usr/bin/sh -# -# An example hook script to make use of push options. -# The example simply echoes all push options that start with 'echoback=' -# and rejects all pushes when the "reject" push option is used. -# -# To enable this hook, rename this file to "pre-receive". - -if test -n "$GIT_PUSH_OPTION_COUNT" -then - i=0 - while test "$i" -lt "$GIT_PUSH_OPTION_COUNT" - do - eval "value=\$GIT_PUSH_OPTION_$i" - case "$value" in - echoback=*) - echo "echo from the pre-receive-hook: ${value#*=}" >&2 - ;; - reject) - exit 1 - esac - i=$((i + 1)) - done -fi diff --git a/test/data/status_data/embedded_git/hooks/prepare-commit-msg.sample b/test/data/status_data/embedded_git/hooks/prepare-commit-msg.sample deleted file mode 100755 index cd8e794..0000000 --- a/test/data/status_data/embedded_git/hooks/prepare-commit-msg.sample +++ /dev/null @@ -1,42 +0,0 @@ -#!/usr/bin/sh -# -# An example hook script to prepare the commit log message. -# Called by "git commit" with the name of the file that has the -# commit message, followed by the description of the commit -# message's source. The hook's purpose is to edit the commit -# message file. If the hook fails with a non-zero status, -# the commit is aborted. -# -# To enable this hook, rename this file to "prepare-commit-msg". - -# This hook includes three examples. The first one removes the -# "# Please enter the commit message..." help message. -# -# The second includes the output of "git diff --name-status -r" -# into the message, just before the "git status" output. It is -# commented because it doesn't cope with --amend or with squashed -# commits. -# -# The third example adds a Signed-off-by line to the message, that can -# still be edited. This is rarely a good idea. - -COMMIT_MSG_FILE=$1 -COMMIT_SOURCE=$2 -SHA1=$3 - -/usr/bin/perl -i.bak -ne 'print unless(m/^. Please enter the commit message/..m/^#$/)' "$COMMIT_MSG_FILE" - -# case "$COMMIT_SOURCE,$SHA1" in -# ,|template,) -# /usr/bin/perl -i.bak -pe ' -# print "\n" . `git diff --cached --name-status -r` -# if /^#/ && $first++ == 0' "$COMMIT_MSG_FILE" ;; -# *) ;; -# esac - -# SOB=$(git var GIT_COMMITTER_IDENT | sed -n 's/^\(.*>\).*$/Signed-off-by: \1/p') -# git interpret-trailers --in-place --trailer "$SOB" "$COMMIT_MSG_FILE" -# if test -z "$COMMIT_SOURCE" -# then -# /usr/bin/perl -i.bak -pe 'print "\n" if !$first_line++' "$COMMIT_MSG_FILE" -# fi diff --git a/test/data/status_data/embedded_git/hooks/push-to-checkout.sample b/test/data/status_data/embedded_git/hooks/push-to-checkout.sample deleted file mode 100755 index badb4b4..0000000 --- a/test/data/status_data/embedded_git/hooks/push-to-checkout.sample +++ /dev/null @@ -1,78 +0,0 @@ -#!/usr/bin/sh - -# An example hook script to update a checked-out tree on a git push. -# -# This hook is invoked by git-receive-pack(1) when it reacts to git -# push and updates reference(s) in its repository, and when the push -# tries to update the branch that is currently checked out and the -# receive.denyCurrentBranch configuration variable is set to -# updateInstead. -# -# By default, such a push is refused if the working tree and the index -# of the remote repository has any difference from the currently -# checked out commit; when both the working tree and the index match -# the current commit, they are updated to match the newly pushed tip -# of the branch. This hook is to be used to override the default -# behaviour; however the code below reimplements the default behaviour -# as a starting point for convenient modification. -# -# The hook receives the commit with which the tip of the current -# branch is going to be updated: -commit=$1 - -# It can exit with a non-zero status to refuse the push (when it does -# so, it must not modify the index or the working tree). -die () { - echo >&2 "$*" - exit 1 -} - -# Or it can make any necessary changes to the working tree and to the -# index to bring them to the desired state when the tip of the current -# branch is updated to the new commit, and exit with a zero status. -# -# For example, the hook can simply run git read-tree -u -m HEAD "$1" -# in order to emulate git fetch that is run in the reverse direction -# with git push, as the two-tree form of git read-tree -u -m is -# essentially the same as git switch or git checkout that switches -# branches while keeping the local changes in the working tree that do -# not interfere with the difference between the branches. - -# The below is a more-or-less exact translation to shell of the C code -# for the default behaviour for git's push-to-checkout hook defined in -# the push_to_deploy() function in builtin/receive-pack.c. -# -# Note that the hook will be executed from the repository directory, -# not from the working tree, so if you want to perform operations on -# the working tree, you will have to adapt your code accordingly, e.g. -# by adding "cd .." or using relative paths. - -if ! git update-index -q --ignore-submodules --refresh -then - die "Up-to-date check failed" -fi - -if ! git diff-files --quiet --ignore-submodules -- -then - die "Working directory has unstaged changes" -fi - -# This is a rough translation of: -# -# head_has_history() ? "HEAD" : EMPTY_TREE_SHA1_HEX -if git cat-file -e HEAD 2>/dev/null -then - head=HEAD -else - head=$(git hash-object -t tree --stdin &2 - exit 1 -} - -unset GIT_DIR GIT_WORK_TREE -cd "$worktree" && - -if grep -q "^diff --git " "$1" -then - validate_patch "$1" -else - validate_cover_letter "$1" -fi && - -if test "$GIT_SENDEMAIL_FILE_COUNTER" = "$GIT_SENDEMAIL_FILE_TOTAL" -then - git config --unset-all sendemail.validateWorktree && - trap 'git worktree remove -ff "$worktree"' EXIT && - validate_series -fi diff --git a/test/data/status_data/embedded_git/hooks/update.sample b/test/data/status_data/embedded_git/hooks/update.sample deleted file mode 100755 index 5503c12..0000000 --- a/test/data/status_data/embedded_git/hooks/update.sample +++ /dev/null @@ -1,128 +0,0 @@ -#!/usr/bin/sh -# -# An example hook script to block unannotated tags from entering. -# Called by "git receive-pack" with arguments: refname sha1-old sha1-new -# -# To enable this hook, rename this file to "update". -# -# Config -# ------ -# hooks.allowunannotated -# This boolean sets whether unannotated tags will be allowed into the -# repository. By default they won't be. -# hooks.allowdeletetag -# This boolean sets whether deleting tags will be allowed in the -# repository. By default they won't be. -# hooks.allowmodifytag -# This boolean sets whether a tag may be modified after creation. By default -# it won't be. -# hooks.allowdeletebranch -# This boolean sets whether deleting branches will be allowed in the -# repository. By default they won't be. -# hooks.denycreatebranch -# This boolean sets whether remotely creating branches will be denied -# in the repository. By default this is allowed. -# - -# --- Command line -refname="$1" -oldrev="$2" -newrev="$3" - -# --- Safety check -if [ -z "$GIT_DIR" ]; then - echo "Don't run this script from the command line." >&2 - echo " (if you want, you could supply GIT_DIR then run" >&2 - echo " $0 )" >&2 - exit 1 -fi - -if [ -z "$refname" -o -z "$oldrev" -o -z "$newrev" ]; then - echo "usage: $0 " >&2 - exit 1 -fi - -# --- Config -allowunannotated=$(git config --type=bool hooks.allowunannotated) -allowdeletebranch=$(git config --type=bool hooks.allowdeletebranch) -denycreatebranch=$(git config --type=bool hooks.denycreatebranch) -allowdeletetag=$(git config --type=bool hooks.allowdeletetag) -allowmodifytag=$(git config --type=bool hooks.allowmodifytag) - -# check for no description -projectdesc=$(sed -e '1q' "$GIT_DIR/description") -case "$projectdesc" in -"Unnamed repository"* | "") - echo "*** Project description file hasn't been set" >&2 - exit 1 - ;; -esac - -# --- Check types -# if $newrev is 0000...0000, it's a commit to delete a ref. -zero=$(git hash-object --stdin &2 - echo "*** Use 'git tag [ -a | -s ]' for tags you want to propagate." >&2 - exit 1 - fi - ;; - refs/tags/*,delete) - # delete tag - if [ "$allowdeletetag" != "true" ]; then - echo "*** Deleting a tag is not allowed in this repository" >&2 - exit 1 - fi - ;; - refs/tags/*,tag) - # annotated tag - if [ "$allowmodifytag" != "true" ] && git rev-parse $refname > /dev/null 2>&1 - then - echo "*** Tag '$refname' already exists." >&2 - echo "*** Modifying a tag is not allowed in this repository." >&2 - exit 1 - fi - ;; - refs/heads/*,commit) - # branch - if [ "$oldrev" = "$zero" -a "$denycreatebranch" = "true" ]; then - echo "*** Creating a branch is not allowed in this repository" >&2 - exit 1 - fi - ;; - refs/heads/*,delete) - # delete branch - if [ "$allowdeletebranch" != "true" ]; then - echo "*** Deleting a branch is not allowed in this repository" >&2 - exit 1 - fi - ;; - refs/remotes/*,commit) - # tracking branch - ;; - refs/remotes/*,delete) - # delete tracking branch - if [ "$allowdeletebranch" != "true" ]; then - echo "*** Deleting a tracking branch is not allowed in this repository" >&2 - exit 1 - fi - ;; - *) - # Anything else (is there anything else?) - echo "*** Update hook: unknown type of update to ref $refname of type $newrev_type" >&2 - exit 1 - ;; -esac - -# --- Finished -exit 0 diff --git a/test/data/status_data/embedded_git/index b/test/data/status_data/embedded_git/index deleted file mode 100644 index 95e66756d042e8caa34a150d0f4b443a8426c817..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 294 zcmZ?q402{*U|<4b=8P_5LH$EMvtTq(jDf*v`%4Cf#w9@M7ZA)KHfQCE!tC!~SId9# z+{ZJ0fmhW!rz8g9{F02+qWF^3;*$8Z%$!ucl8O?r0SpWoU1rbp^k*@_Xo$JH4xyWS z$C=MYFHdmt=5iKBwVeSo)#Qo-8TgA5^HTD2k 1750769952 +0200 commit (initial): first commit -75743dcbd85064226c77a0b862af817838ae0b2e ee8c4cf874c4f1e3ba755f929fe7811018adee3d Sandrine Pataut 1750771272 +0200 commit: Second commit -ee8c4cf874c4f1e3ba755f929fe7811018adee3d ee8c4cf874c4f1e3ba755f929fe7811018adee3d Sandrine Pataut 1753887403 +0200 checkout: moving from main to bla -ee8c4cf874c4f1e3ba755f929fe7811018adee3d ee8c4cf874c4f1e3ba755f929fe7811018adee3d Sandrine Pataut 1753887403 +0200 checkout: moving from bla to main -ee8c4cf874c4f1e3ba755f929fe7811018adee3d ee8c4cf874c4f1e3ba755f929fe7811018adee3d Sandrine Pataut 1753887403 +0200 checkout: moving from main to bla -ee8c4cf874c4f1e3ba755f929fe7811018adee3d ee8c4cf874c4f1e3ba755f929fe7811018adee3d Sandrine Pataut 1753887403 +0200 checkout: moving from bla to main -ee8c4cf874c4f1e3ba755f929fe7811018adee3d ee8c4cf874c4f1e3ba755f929fe7811018adee3d Sandrine Pataut 1753887403 +0200 checkout: moving from main to bla -ee8c4cf874c4f1e3ba755f929fe7811018adee3d ee8c4cf874c4f1e3ba755f929fe7811018adee3d Sandrine Pataut 1753887403 +0200 checkout: moving from bla to main -ee8c4cf874c4f1e3ba755f929fe7811018adee3d ee8c4cf874c4f1e3ba755f929fe7811018adee3d Sandrine Pataut 1753887403 +0200 checkout: moving from main to bla -ee8c4cf874c4f1e3ba755f929fe7811018adee3d ee8c4cf874c4f1e3ba755f929fe7811018adee3d Sandrine Pataut 1753887403 +0200 checkout: moving from bla to main -ee8c4cf874c4f1e3ba755f929fe7811018adee3d ee8c4cf874c4f1e3ba755f929fe7811018adee3d Sandrine Pataut 1753887505 +0200 checkout: moving from main to commit_test_branch -ee8c4cf874c4f1e3ba755f929fe7811018adee3d cba545ef5cc4ddf12a9744b6a49b20dda1ef1d5c Sandrine Pataut 1753887505 +0200 commit: test commit -cba545ef5cc4ddf12a9744b6a49b20dda1ef1d5c ee8c4cf874c4f1e3ba755f929fe7811018adee3d Sandrine Pataut 1753887505 +0200 reset: moving to ee8c4cf874c4f1e3ba755f929fe7811018adee3d -ee8c4cf874c4f1e3ba755f929fe7811018adee3d ee8c4cf874c4f1e3ba755f929fe7811018adee3d Sandrine Pataut 1753887505 +0200 checkout: moving from commit_test_branch to main -ee8c4cf874c4f1e3ba755f929fe7811018adee3d ee8c4cf874c4f1e3ba755f929fe7811018adee3d Sandrine Pataut 1753887505 +0200 checkout: moving from main to commit_test_branch -ee8c4cf874c4f1e3ba755f929fe7811018adee3d 75743dcbd85064226c77a0b862af817838ae0b2e Sandrine Pataut 1753888486 +0200 reset: moving to 75743dcbd85064226c77a0b862af817838ae0b2e -75743dcbd85064226c77a0b862af817838ae0b2e ee8c4cf874c4f1e3ba755f929fe7811018adee3d Sandrine Pataut 1753888486 +0200 checkout: moving from commit_test_branch to main -ee8c4cf874c4f1e3ba755f929fe7811018adee3d ee8c4cf874c4f1e3ba755f929fe7811018adee3d Sandrine Pataut 1753888486 +0200 checkout: moving from main to commit_test_branch diff --git a/test/data/status_data/embedded_git/logs/refs/heads/commit_test_branch b/test/data/status_data/embedded_git/logs/refs/heads/commit_test_branch deleted file mode 100644 index 0d35c1b..0000000 --- a/test/data/status_data/embedded_git/logs/refs/heads/commit_test_branch +++ /dev/null @@ -1 +0,0 @@ -0000000000000000000000000000000000000000 ee8c4cf874c4f1e3ba755f929fe7811018adee3d Sandrine Pataut 1753888486 +0200 branch: Created from ee8c4cf874c4f1e3ba755f929fe7811018adee3d diff --git a/test/data/status_data/embedded_git/logs/refs/heads/main b/test/data/status_data/embedded_git/logs/refs/heads/main deleted file mode 100644 index a996210..0000000 --- a/test/data/status_data/embedded_git/logs/refs/heads/main +++ /dev/null @@ -1,2 +0,0 @@ -0000000000000000000000000000000000000000 75743dcbd85064226c77a0b862af817838ae0b2e Sandrine Pataut 1750769952 +0200 commit (initial): first commit -75743dcbd85064226c77a0b862af817838ae0b2e ee8c4cf874c4f1e3ba755f929fe7811018adee3d Sandrine Pataut 1750771272 +0200 commit: Second commit diff --git a/test/data/status_data/embedded_git/objects/75/743dcbd85064226c77a0b862af817838ae0b2e b/test/data/status_data/embedded_git/objects/75/743dcbd85064226c77a0b862af817838ae0b2e deleted file mode 100644 index fbfd68ec05d063ed93b33efe96ff37fbb5e8784d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 132 zcmV-~0DJ#<0i}&e3IZ_@06pgweHVr-W&;tQAozk#($RpKkxbA3h0z~)DyTzMUV3i~ zD#im3BB;eGRa3I1=-^~ROjQTcF;q0h8)r@woRb1Kel7#J|5ODXa`XZkZEY8xxVw7Hf1n0FfcPQQOGaJNG*ylNi8mkPs_|njn7Lh*DI+gVVJXW zMPc^$udC(1c<$qwzQC($ol_E2OHpE8N`5X_Iaui(XFeOfJi*DE%UKxJb_UE;lPd~@ jDutU1R=D@Qa}N8v&bgAQQyVW$@ej8;rtkm&{mnMHv`;zj diff --git a/test/data/status_data/embedded_git/objects/bd/ef436c07ee899d19659581d2944f573ac620e0 b/test/data/status_data/embedded_git/objects/bd/ef436c07ee899d19659581d2944f573ac620e0 deleted file mode 100644 index 0d7359169376ffdedc5e8554179ea6105275ddbc..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 45 zcmb~$>tA4tgHp~0OMq$3vO1{cz+l5238+D#mmRz zF!T+K$3>e&fGmbYDWVS-Mia=G%+43_Id6 diff --git a/test/data/status_data/embedded_git/objects/dc/430e3c2e6e1193b377040126b95099261e7251 b/test/data/status_data/embedded_git/objects/dc/430e3c2e6e1193b377040126b95099261e7251 deleted file mode 100644 index fd8249d65fc0856ff2f51818d9f40eadce1d18e7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 38 ucmb7He@g~FfcPQQOGaJNG*ylNi8mkPs_|n)hnqeVVJXWMPc^$ zudC(1c<$qwzQC($ol_E2c~N3sN`5Xv=^bZ28@)Wi$(zer7}a(L%v6&r3WO>}HFNKK b=N$HTopU8qr#4=i;va5xOyL0l8rd}r|En|k diff --git a/test/data/status_data/embedded_git/objects/ee/8c4cf874c4f1e3ba755f929fe7811018adee3d b/test/data/status_data/embedded_git/objects/ee/8c4cf874c4f1e3ba755f929fe7811018adee3d deleted file mode 100644 index ce96d7b..0000000 --- a/test/data/status_data/embedded_git/objects/ee/8c4cf874c4f1e3ba755f929fe7811018adee3d +++ /dev/null @@ -1,2 +0,0 @@ -xK -1 @] &M xX/7p-.46wm3#B*V+qUD咘ɭ@X"}/99H W/\uq6hWۤPA؋G]63VoνOu \ No newline at end of file diff --git a/test/data/status_data/embedded_git/objects/fd/511fbb5dd2860baabf28e298fa3373634e8660 b/test/data/status_data/embedded_git/objects/fd/511fbb5dd2860baabf28e298fa3373634e8660 deleted file mode 100644 index 476418f149af2f35d0c21cfe392c53b5a4718440..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 89 zcmV-f0H*(V0V^p=O;xb4U@$Z=Ff%bx$S=uAEs8HmEiQ>q%gjmDE2$`9n6q+4VfOd0 vtL49V?&F!hz^iJVQxa4;s=~eRopadVb Date: Fri, 8 Aug 2025 11:05:35 +0200 Subject: [PATCH 002/116] add returncode check for subprocesses (#34) --- test/test_add.py | 4 +++- test/test_branch.py | 14 +++++++++----- test/test_checkout.py | 35 ++++++++++++++++++++++------------- test/test_clone.py | 6 ++++-- test/test_commit.py | 10 ++++++---- test/test_status.py | 4 +++- 6 files changed, 47 insertions(+), 26 deletions(-) diff --git a/test/test_add.py b/test/test_add.py index f93b5a2..39f0aaa 100644 --- a/test/test_add.py +++ b/test/test_add.py @@ -19,10 +19,12 @@ def test_add(xtl_clone, git2cpp_path, all_flag): cmd_add.append(all_flag) else: cmd_add.append("mook_file.txt") - subprocess.run(cmd_add, cwd=working_dir, text=True) + p_add = subprocess.run(cmd_add, cwd=working_dir, text=True) + assert p_add.returncode == 0 cmd_status = [git2cpp_path, 'status', "--long"] p_status = subprocess.run(cmd_status, cwd=working_dir, capture_output=True, text=True) + assert p_status.returncode == 0 assert "Changes to be committed" in p_status.stdout assert "new file" in p_status.stdout diff --git a/test/test_branch.py b/test/test_branch.py index a30ce51..c2d9c54 100644 --- a/test/test_branch.py +++ b/test/test_branch.py @@ -9,17 +9,21 @@ def test_branch_list(xtl_clone, git2cpp_path): cmd = [git2cpp_path, 'branch'] p = subprocess.run(cmd, capture_output=True, cwd=working_dir, text=True) + assert p.returncode == 0 assert(p.stdout == '* master\n') def test_branch_create_delete(xtl_clone, git2cpp_path): create_cmd = [git2cpp_path, 'branch', 'foregone'] - subprocess.run(create_cmd, capture_output=True, cwd=working_dir, text=True) + p_create = subprocess.run(create_cmd, capture_output=True, cwd=working_dir, text=True) + assert p_create.returncode == 0 list_cmd = [git2cpp_path, 'branch'] - p = subprocess.run(list_cmd, capture_output=True, cwd=working_dir, text=True) - assert(p.stdout == ' foregone\n* master\n') + p_list = subprocess.run(list_cmd, capture_output=True, cwd=working_dir, text=True) + assert p_list.returncode == 0 + assert(p_list.stdout == ' foregone\n* master\n') del_cmd = [git2cpp_path, 'branch', '-d', 'foregone'] subprocess.run(del_cmd, capture_output=True, cwd=working_dir, text=True) - p2 = subprocess.run(list_cmd, capture_output=True, cwd=working_dir, text=True) - assert(p2.stdout == '* master\n') + p_list2 = subprocess.run(list_cmd, capture_output=True, cwd=working_dir, text=True) + assert p_list2.returncode == 0 + assert(p_list2.stdout == '* master\n') diff --git a/test/test_checkout.py b/test/test_checkout.py index 6927e26..279c31b 100644 --- a/test/test_checkout.py +++ b/test/test_checkout.py @@ -8,35 +8,44 @@ def test_checkout(xtl_clone, git2cpp_path): create_cmd = [git2cpp_path, 'branch', 'foregone'] - subprocess.run(create_cmd, capture_output=True, cwd=working_dir, text=True) + p_create = subprocess.run(create_cmd, capture_output=True, cwd=working_dir, text=True) + assert p_create.returncode == 0 checkout_cmd = [git2cpp_path, 'checkout', 'foregone'] - p = subprocess.run(checkout_cmd, capture_output=True, cwd=working_dir, text=True) - assert(p.stdout == ''); + p_checkout = subprocess.run(checkout_cmd, capture_output=True, cwd=working_dir, text=True) + assert p_checkout.returncode == 0 + assert(p_checkout.stdout == ''); branch_cmd = [git2cpp_path, 'branch'] - p2 = subprocess.run(branch_cmd, capture_output=True, cwd=working_dir, text=True) - assert(p2.stdout == '* foregone\n master\n') + p_branch = subprocess.run(branch_cmd, capture_output=True, cwd=working_dir, text=True) + assert p_branch.returncode == 0 + assert(p_branch.stdout == '* foregone\n master\n') checkout_cmd[2] = 'master' - subprocess.run(checkout_cmd, capture_output=True, cwd=working_dir, text=True) + p_checkout2 = subprocess.run(checkout_cmd, capture_output=True, cwd=working_dir, text=True) + assert p_checkout2.returncode == 0 del_cmd = [git2cpp_path, 'branch', '-d', 'foregone'] - subprocess.run(del_cmd, cwd=working_dir, text=True) + p_del = subprocess.run(del_cmd, cwd=working_dir, text=True) + assert p_del.returncode == 0 def test_checkout_b(xtl_clone, git2cpp_path): checkout_cmd = [git2cpp_path, 'checkout', '-b', 'foregone'] - p = subprocess.run(checkout_cmd, capture_output=True, cwd=working_dir, text=True) - assert(p.stdout == ''); + p_checkout = subprocess.run(checkout_cmd, capture_output=True, cwd=working_dir, text=True) + assert p_checkout.returncode == 0 + assert(p_checkout.stdout == ''); branch_cmd = [git2cpp_path, 'branch'] - p2 = subprocess.run(branch_cmd, capture_output=True, cwd=working_dir, text=True) - assert(p2.stdout == '* foregone\n master\n') + p_branch = subprocess.run(branch_cmd, capture_output=True, cwd=working_dir, text=True) + assert p_branch.returncode == 0 + assert(p_branch.stdout == '* foregone\n master\n') checkout_cmd.remove('-b') checkout_cmd[2] = 'master' - subprocess.run(checkout_cmd, cwd=working_dir, text=True) + p_checkout2 = subprocess.run(checkout_cmd, cwd=working_dir, text=True) + assert p_checkout2.returncode == 0 del_cmd = [git2cpp_path, 'branch', '-d', 'foregone'] - subprocess.run(del_cmd, cwd=working_dir, text=True) + p_del = subprocess.run(del_cmd, cwd=working_dir, text=True) + assert p_del.returncode == 0 diff --git a/test/test_clone.py b/test/test_clone.py index 70b5bc8..5e63bd3 100644 --- a/test/test_clone.py +++ b/test/test_clone.py @@ -9,10 +9,12 @@ def test_clone(git2cpp_path): working_dir = 'test/data' clone_cmd = [git2cpp_path, 'clone', url] - subprocess.run(clone_cmd, capture_output=True, cwd = working_dir, text=True) + p_clone = subprocess.run(clone_cmd, capture_output=True, cwd = working_dir, text=True) + assert p_clone.returncode == 0 assert os.path.exists(working_dir + '/xtl') assert os.path.exists(working_dir + '/xtl/include') cleanup_cmd = ['rm', '-rf', 'xtl'] - subprocess.run(cleanup_cmd, capture_output=True, cwd = working_dir, text=True) + p_cleanup = subprocess.run(cleanup_cmd, capture_output=True, cwd = working_dir, text=True) + assert p_cleanup.returncode == 0 diff --git a/test/test_commit.py b/test/test_commit.py index 5d394b9..dbd614e 100644 --- a/test/test_commit.py +++ b/test/test_commit.py @@ -12,18 +12,20 @@ def test_commit(xtl_clone, git_config, git2cpp_path, monkeypatch, all_flag): pass cmd_add = [git2cpp_path, 'add', "mook_file.txt"] - subprocess.run(cmd_add, cwd=working_dir, text=True) + p_add = subprocess.run(cmd_add, cwd=working_dir, text=True) + assert p_add.returncode == 0 cmd_status = [git2cpp_path, 'status', "--long"] p_status = subprocess.run(cmd_status, capture_output=True, cwd=working_dir, 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"] - subprocess.run(cmd_commit, cwd=working_dir, text=True) + p_commit = subprocess.run(cmd_commit, cwd=working_dir, 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=working_dir, text=True) - + assert p_status_2.returncode == 0 assert "mook_file" not in p_status_2.stdout diff --git a/test/test_status.py b/test/test_status.py index 1490a2e..50affb5 100644 --- a/test/test_status.py +++ b/test/test_status.py @@ -24,6 +24,7 @@ def test_status_new_file(xtl_clone, git2cpp_path, short_flag, long_flag): if long_flag != "": cmd.append(long_flag) p = subprocess.run(cmd, capture_output=True, cwd=working_dir, text=True) + assert p.returncode == 0 if (long_flag == "--long") or ((long_flag == "") & (short_flag == "")): assert "On branch master" in p.stdout @@ -47,7 +48,8 @@ def test_status_add_file(xtl_clone, git2cpp_path, short_flag, long_flag): os.remove("./test/data/xtl/README.md") # Changes to be committed / deleted cmd_add = [git2cpp_path, 'add', "--all"] - subprocess.run(cmd_add, cwd=working_dir, text=True) + p = subprocess.run(cmd_add, cwd=working_dir, text=True) + assert p.returncode == 0 cmd_status = [git2cpp_path, 'status'] if short_flag != "": From 9351a2f5400303f510765c0d9060b7b5be8c14b0 Mon Sep 17 00:00:00 2001 From: Sandrine Pataut Date: Fri, 8 Aug 2025 17:43:18 +0200 Subject: [PATCH 003/116] Use of pytest fixture tmp_path in all tests (#35) * add tmp_path * fix path issue * remove cleaning up in clone fixture * add assert to checkout test * small fix * remove -f * remove useless comment * remove useless run_in_tmp_path --- test/conftest.py | 11 ++------- test/test_add.py | 25 +++++++++------------ test/test_branch.py | 23 ++++++++++++------- test/test_checkout.py | 32 +++++++++++++------------- test/test_clone.py | 13 ++++------- test/test_commit.py | 20 +++++++++-------- test/test_status.py | 52 +++++++++++++++++++++++-------------------- 7 files changed, 86 insertions(+), 90 deletions(-) diff --git a/test/conftest.py b/test/conftest.py index b84b04a..576b5a3 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -17,17 +17,10 @@ def git2cpp_path(): return Path(__file__).parent.parent / 'build' / 'git2cpp' @pytest.fixture -def xtl_clone(git2cpp_path): +def xtl_clone(git2cpp_path, tmp_path, run_in_tmp_path): url = 'https://github.com/xtensor-stack/xtl.git' - clone_working_dir = 'test/data' - clone_cmd = [git2cpp_path, 'clone', url] - subprocess.run(clone_cmd, capture_output=True, cwd = clone_working_dir, text=True) - - yield - - cleanup_cmd = ['rm', '-rf', 'xtl'] - subprocess.run(cleanup_cmd, capture_output=True, cwd = clone_working_dir, text=True) + subprocess.run(clone_cmd, capture_output=True, cwd = tmp_path, text=True) @pytest.fixture def git_config(monkeypatch): diff --git a/test/test_add.py b/test/test_add.py index 39f0aaa..92c767c 100644 --- a/test/test_add.py +++ b/test/test_add.py @@ -4,26 +4,27 @@ import pytest -working_dir = 'test/data/xtl' - @pytest.mark.parametrize("all_flag", ["", "-A", "--all", "--no-ignore-removal"]) -def test_add(xtl_clone, git2cpp_path, all_flag): - with open("./test/data/xtl/mook_file.txt", "x"): - pass +def test_add(xtl_clone, git2cpp_path, tmp_path, all_flag): + assert (tmp_path / "xtl").exists() + xtl_path = tmp_path / "xtl" + + p = xtl_path / "mook_file.txt" + p.write_text('') - with open("./test/data/xtl/mook_file_2.txt", "x"): - pass + p2 = xtl_path / "mook_file_2.txt" + p2.write_text('') 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=working_dir, text=True) + p_add = subprocess.run(cmd_add, cwd=xtl_path, text=True) assert p_add.returncode == 0 cmd_status = [git2cpp_path, 'status', "--long"] - p_status = subprocess.run(cmd_status, cwd=working_dir, capture_output=True, text=True) + p_status = subprocess.run(cmd_status, cwd=xtl_path, capture_output=True, text=True) assert p_status.returncode == 0 assert "Changes to be committed" in p_status.stdout @@ -32,9 +33,3 @@ def test_add(xtl_clone, git2cpp_path, all_flag): assert "Untracked files" not in p_status.stdout else: assert "Untracked files" in p_status.stdout - - os.remove("./test/data/xtl/mook_file.txt") - os.remove("./test/data/xtl/mook_file_2.txt") - - # undo the add, to leave the test directory at the end the same as it was at the start - subprocess.run(cmd_add, cwd=working_dir, capture_output=True, text=True) diff --git a/test/test_branch.py b/test/test_branch.py index c2d9c54..0439406 100644 --- a/test/test_branch.py +++ b/test/test_branch.py @@ -4,26 +4,33 @@ import pytest -working_dir = 'test/data/xtl' +def test_branch_list(xtl_clone, git2cpp_path, tmp_path): + assert (tmp_path / "xtl").exists() + xtl_path = tmp_path / "xtl" -def test_branch_list(xtl_clone, git2cpp_path): cmd = [git2cpp_path, 'branch'] - p = subprocess.run(cmd, capture_output=True, cwd=working_dir, text=True) + p = subprocess.run(cmd, capture_output=True, cwd=xtl_path, text=True) assert p.returncode == 0 assert(p.stdout == '* master\n') -def test_branch_create_delete(xtl_clone, git2cpp_path): +def test_branch_create_delete(xtl_clone, git2cpp_path, tmp_path): + assert (tmp_path / "xtl").exists() + xtl_path = tmp_path / "xtl" + create_cmd = [git2cpp_path, 'branch', 'foregone'] - p_create = subprocess.run(create_cmd, capture_output=True, cwd=working_dir, text=True) + p_create = subprocess.run(create_cmd, capture_output=True, cwd=xtl_path, text=True) assert p_create.returncode == 0 + list_cmd = [git2cpp_path, 'branch'] - p_list = subprocess.run(list_cmd, capture_output=True, cwd=working_dir, text=True) + p_list = subprocess.run(list_cmd, capture_output=True, cwd=xtl_path, text=True) assert p_list.returncode == 0 assert(p_list.stdout == ' foregone\n* master\n') del_cmd = [git2cpp_path, 'branch', '-d', 'foregone'] - subprocess.run(del_cmd, capture_output=True, cwd=working_dir, text=True) - p_list2 = subprocess.run(list_cmd, capture_output=True, cwd=working_dir, text=True) + p_del = subprocess.run(del_cmd, capture_output=True, cwd=xtl_path, text=True) + assert p_del.returncode == 0 + + p_list2 = subprocess.run(list_cmd, capture_output=True, cwd=xtl_path, text=True) assert p_list2.returncode == 0 assert(p_list2.stdout == '* master\n') diff --git a/test/test_checkout.py b/test/test_checkout.py index 279c31b..eb68db0 100644 --- a/test/test_checkout.py +++ b/test/test_checkout.py @@ -4,48 +4,48 @@ import pytest -working_dir = 'test/data/xtl' +def test_checkout(xtl_clone, git2cpp_path, tmp_path): + assert (tmp_path / "xtl").exists() + xtl_path = tmp_path / "xtl" -def test_checkout(xtl_clone, git2cpp_path): create_cmd = [git2cpp_path, 'branch', 'foregone'] - p_create = subprocess.run(create_cmd, capture_output=True, cwd=working_dir, text=True) + 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=working_dir, text=True) + p_checkout = subprocess.run(checkout_cmd, capture_output=True, cwd=xtl_path, text=True) assert p_checkout.returncode == 0 assert(p_checkout.stdout == ''); branch_cmd = [git2cpp_path, 'branch'] - p_branch = subprocess.run(branch_cmd, capture_output=True, cwd=working_dir, text=True) + 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') checkout_cmd[2] = 'master' - p_checkout2 = subprocess.run(checkout_cmd, capture_output=True, cwd=working_dir, text=True) + p_checkout2 = subprocess.run(checkout_cmd, capture_output=True, cwd=xtl_path, text=True) assert p_checkout2.returncode == 0 - del_cmd = [git2cpp_path, 'branch', '-d', 'foregone'] - p_del = subprocess.run(del_cmd, cwd=working_dir, text=True) - assert p_del.returncode == 0 +def test_checkout_b(xtl_clone, git2cpp_path, tmp_path): + assert (tmp_path / "xtl").exists() + xtl_path = tmp_path / "xtl" -def test_checkout_b(xtl_clone, git2cpp_path): checkout_cmd = [git2cpp_path, 'checkout', '-b', 'foregone'] - p_checkout = subprocess.run(checkout_cmd, capture_output=True, cwd=working_dir, text=True) + p_checkout = subprocess.run(checkout_cmd, capture_output=True, cwd=xtl_path, text=True) assert p_checkout.returncode == 0 assert(p_checkout.stdout == ''); branch_cmd = [git2cpp_path, 'branch'] - p_branch = subprocess.run(branch_cmd, capture_output=True, cwd=working_dir, text=True) + 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') checkout_cmd.remove('-b') checkout_cmd[2] = 'master' - p_checkout2 = subprocess.run(checkout_cmd, cwd=working_dir, text=True) + p_checkout2 = subprocess.run(checkout_cmd, cwd=xtl_path, text=True) assert p_checkout2.returncode == 0 - del_cmd = [git2cpp_path, 'branch', '-d', 'foregone'] - p_del = subprocess.run(del_cmd, cwd=working_dir, text=True) - assert p_del.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') diff --git a/test/test_clone.py b/test/test_clone.py index 5e63bd3..7ada28e 100644 --- a/test/test_clone.py +++ b/test/test_clone.py @@ -4,17 +4,12 @@ import pytest -def test_clone(git2cpp_path): +def test_clone(git2cpp_path, tmp_path, run_in_tmp_path): url = 'https://github.com/xtensor-stack/xtl.git' - working_dir = 'test/data' clone_cmd = [git2cpp_path, 'clone', url] - p_clone = subprocess.run(clone_cmd, capture_output=True, cwd = working_dir, text=True) + p_clone = subprocess.run(clone_cmd, capture_output=True, cwd = tmp_path, text=True) assert p_clone.returncode == 0 - assert os.path.exists(working_dir + '/xtl') - assert os.path.exists(working_dir + '/xtl/include') - - cleanup_cmd = ['rm', '-rf', 'xtl'] - p_cleanup = subprocess.run(cleanup_cmd, capture_output=True, cwd = working_dir, text=True) - assert p_cleanup.returncode == 0 + assert os.path.exists(os.path.join(tmp_path, 'xtl')) + assert os.path.exists(os.path.join(tmp_path, 'xtl/include')) diff --git a/test/test_commit.py b/test/test_commit.py index dbd614e..932dd02 100644 --- a/test/test_commit.py +++ b/test/test_commit.py @@ -4,28 +4,30 @@ import pytest -working_dir = 'test/data/xtl' - @pytest.mark.parametrize("all_flag", ["", "-A", "--all", "--no-ignore-removal"]) -def test_commit(xtl_clone, git_config, git2cpp_path, monkeypatch, all_flag): - with open("./test/data/xtl/mook_file.txt", "x"): - pass +def test_commit(xtl_clone, git_config, git2cpp_path, tmp_path, monkeypatch, all_flag): + assert (tmp_path / "xtl").exists() + xtl_path = tmp_path / "xtl" + + p = xtl_path / "mook_file.txt" + p.write_text('') cmd_add = [git2cpp_path, 'add', "mook_file.txt"] - p_add = subprocess.run(cmd_add, cwd=working_dir, text=True) + p_add = subprocess.run(cmd_add, cwd=xtl_path, text=True) assert p_add.returncode == 0 cmd_status = [git2cpp_path, 'status', "--long"] - p_status = subprocess.run(cmd_status, capture_output=True, cwd=working_dir, text=True) + p_status = subprocess.run(cmd_status, capture_output=True, cwd=xtl_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=working_dir, text=True) + p_commit = subprocess.run(cmd_commit, cwd=xtl_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=working_dir, text=True) + p_status_2 = subprocess.run(cmd_status_2, capture_output=True, cwd=xtl_path, text=True) assert p_status_2.returncode == 0 assert "mook_file" not in p_status_2.stdout diff --git a/test/test_status.py b/test/test_status.py index 50affb5..cfacb7b 100644 --- a/test/test_status.py +++ b/test/test_status.py @@ -5,26 +5,26 @@ import pytest -working_dir = 'test/data/xtl' - @pytest.mark.parametrize("short_flag", ["", "-s", "--short"]) @pytest.mark.parametrize("long_flag", ["", "--long"]) -def test_status_new_file(xtl_clone, git2cpp_path, short_flag, long_flag): - with open("./test/data/xtl/mook_file.txt", "x"): # Untracked files - pass +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 + p.write_text('') - with open("./test/data/xtl/CMakeLists.txt", "a") as f: # Changes not staged for commit / modified - f.write("blablabla") + pw = xtl_path / "CMakeLists.txt" # Changes not staged for commit / modified + pw.write_text("blablabla") - os.remove("./test/data/xtl/README.md") # Changes not staged for commit / deleted + os.remove(xtl_path / "README.md") # 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=working_dir, text=True) - assert p.returncode == 0 + p = subprocess.run(cmd, capture_output=True, cwd=xtl_path, text=True) if (long_flag == "--long") or ((long_flag == "") & (short_flag == "")): assert "On branch master" in p.stdout @@ -41,30 +41,34 @@ def test_status_new_file(xtl_clone, git2cpp_path, short_flag, long_flag): @pytest.mark.parametrize("short_flag", ["", "-s", "--short"]) @pytest.mark.parametrize("long_flag", ["", "--long"]) -def test_status_add_file(xtl_clone, git2cpp_path, short_flag, long_flag): - with open("./test/data/xtl/mook_file.txt", "x"): # Changes to be committed / new file - pass +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" - os.remove("./test/data/xtl/README.md") # Changes to be committed / deleted + p = xtl_path / "mook_file.txt" # Changes to be committed / new file + p.write_text('') + + os.remove(xtl_path / "README.md") # Changes to be committed / deleted cmd_add = [git2cpp_path, 'add', "--all"] - p = subprocess.run(cmd_add, cwd=working_dir, text=True) - assert p.returncode == 0 + p_add = subprocess.run(cmd_add, cwd=xtl_path, text=True) + assert p_add.returncode == 0 cmd_status = [git2cpp_path, 'status'] if short_flag != "": cmd_status.append(short_flag) if long_flag != "": cmd_status.append(long_flag) - p = subprocess.run(cmd_status, capture_output=True, cwd=working_dir, text=True) + p_status = subprocess.run(cmd_status, capture_output=True, cwd=xtl_path, text=True) + assert p_status.returncode == 0 if (long_flag == "--long") or ((long_flag == "") & (short_flag == "")): - assert "Changes to be committed" in p.stdout - assert "Changes not staged for commit" not in p.stdout - assert "Untracked files" not in p.stdout - assert "new file" in p.stdout - assert "deleted" in p.stdout + assert "Changes to be committed" in p_status.stdout + assert "Changes not staged for commit" not in p_status.stdout + assert "Untracked files" not in p_status.stdout + assert "new file" in p_status.stdout + assert "deleted" in p_status.stdout elif short_flag in ["-s", "--short"]: - assert "A " in p.stdout - assert "D " in p.stdout + assert "A " in p_status.stdout + assert "D " in p_status.stdout From 4ded7d191dea8f191ca6b8b91dcdc25044199f4d Mon Sep 17 00:00:00 2001 From: Sandrine Pataut Date: Thu, 28 Aug 2025 10:44:06 +0200 Subject: [PATCH 004/116] Add ```log``` subcommand (#38) add log subcommand --- CMakeLists.txt | 2 + src/main.cpp | 2 + src/subcommand/log_subcommand.cpp | 101 ++++++++++++++++++++++++++++++ src/subcommand/log_subcommand.hpp | 21 +++++++ src/wrapper/commit_wrapper.cpp | 6 ++ src/wrapper/commit_wrapper.hpp | 1 + src/wrapper/signature_wrapper.cpp | 36 ++++++++++- src/wrapper/signature_wrapper.hpp | 9 +++ test/test_log.py | 54 ++++++++++++++++ 9 files changed, 231 insertions(+), 1 deletion(-) create mode 100644 src/subcommand/log_subcommand.cpp create mode 100644 src/subcommand/log_subcommand.hpp create mode 100644 test/test_log.py diff --git a/CMakeLists.txt b/CMakeLists.txt index d9d648d..2f5acdb 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -51,6 +51,8 @@ set(GIT2CPP_SRC ${GIT2CPP_SOURCE_DIR}/subcommand/commit_subcommand.hpp ${GIT2CPP_SOURCE_DIR}/subcommand/init_subcommand.cpp ${GIT2CPP_SOURCE_DIR}/subcommand/init_subcommand.hpp + ${GIT2CPP_SOURCE_DIR}/subcommand/log_subcommand.cpp + ${GIT2CPP_SOURCE_DIR}/subcommand/log_subcommand.hpp ${GIT2CPP_SOURCE_DIR}/subcommand/reset_subcommand.cpp ${GIT2CPP_SOURCE_DIR}/subcommand/reset_subcommand.hpp ${GIT2CPP_SOURCE_DIR}/subcommand/status_subcommand.cpp diff --git a/src/main.cpp b/src/main.cpp index 223ddce..71140ad 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -10,6 +10,7 @@ #include "subcommand/clone_subcommand.hpp" #include "subcommand/commit_subcommand.hpp" #include "subcommand/init_subcommand.hpp" +#include "subcommand/log_subcommand.hpp" #include "subcommand/reset_subcommand.hpp" #include "subcommand/status_subcommand.hpp" @@ -33,6 +34,7 @@ int main(int argc, char** argv) clone_subcommand clone(lg2_obj, app); commit_subcommand commit(lg2_obj, app); reset_subcommand reset(lg2_obj, app); + log_subcommand log(lg2_obj, app); app.require_subcommand(/* min */ 0, /* max */ 1); diff --git a/src/subcommand/log_subcommand.cpp b/src/subcommand/log_subcommand.cpp new file mode 100644 index 0000000..c25e270 --- /dev/null +++ b/src/subcommand/log_subcommand.cpp @@ -0,0 +1,101 @@ +#include +#include +#include +#include +#include + +#include "log_subcommand.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("-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->callback([this]() { this->run(); }); +}; + +void print_time(git_time intime, std::string prefix) +{ + char sign, out[32]; + struct tm *intm; + int offset, hours, minutes; + time_t t; + + offset = intime.offset; + if (offset < 0) { + sign = '-'; + offset = -offset; + } + else + { + sign = '+'; + } + + hours = offset / 60; + minutes = offset % 60; + + t = (time_t)intime.time + (intime.offset * 60); + + intm = gmtime(&t); + strftime(out, sizeof(out), "%a %b %e %T %Y", intm); + + std::cout << prefix << out << " " << sign << std::format("{:02d}", hours) << std::format("{:02d}", minutes) < +#include +#include + +#include "../utils/common.hpp" + + +class log_subcommand +{ +public: + + explicit log_subcommand(const libgit2_object&, CLI::App& app); + void run(); + +private: + std::string m_format_flag; + int m_max_count_flag=std::numeric_limits::max(); + // bool m_oneline_flag = false; +}; diff --git a/src/wrapper/commit_wrapper.cpp b/src/wrapper/commit_wrapper.cpp index bbd48b1..a5996fc 100644 --- a/src/wrapper/commit_wrapper.cpp +++ b/src/wrapper/commit_wrapper.cpp @@ -20,3 +20,9 @@ const git_oid& commit_wrapper::oid() const { return *git_commit_id(p_resource); } + +std::string commit_wrapper::commit_oid_tostr() const +{ + char buf[GIT_OID_SHA1_HEXSIZE + 1]; + return git_oid_tostr(buf, sizeof(buf), &this->oid()); +} diff --git a/src/wrapper/commit_wrapper.hpp b/src/wrapper/commit_wrapper.hpp index 2cf2378..d7930d9 100644 --- a/src/wrapper/commit_wrapper.hpp +++ b/src/wrapper/commit_wrapper.hpp @@ -19,6 +19,7 @@ class commit_wrapper : public wrapper_base operator git_object*() const noexcept; const git_oid& oid() const; + std::string commit_oid_tostr() const; private: diff --git a/src/wrapper/signature_wrapper.cpp b/src/wrapper/signature_wrapper.cpp index 4bbb7a6..d16eaf9 100644 --- a/src/wrapper/signature_wrapper.cpp +++ b/src/wrapper/signature_wrapper.cpp @@ -4,10 +4,28 @@ signature_wrapper::~signature_wrapper() { - git_signature_free(p_resource); + if (m_ownership) + { + git_signature_free(p_resource); + } p_resource=nullptr; } +std::string_view signature_wrapper::name() const +{ + return p_resource->name; +} + +std::string_view signature_wrapper::email() const +{ + return p_resource->email; +} + +git_time signature_wrapper::when() const +{ + return p_resource->when; +} + signature_wrapper::author_committer_signatures signature_wrapper::get_default_signature_from_env(repository_wrapper& rw) { signature_wrapper author; @@ -15,3 +33,19 @@ signature_wrapper::author_committer_signatures signature_wrapper::get_default_si throw_if_error(git_signature_default_from_env(&(author.p_resource), &(committer.p_resource), rw)); return {std::move(author), std::move(committer)}; } + +signature_wrapper signature_wrapper::get_commit_author(const commit_wrapper& cw) +{ + signature_wrapper author; + author.p_resource = const_cast(git_commit_author(cw)); + author.m_ownership = false; + return author; +} + +signature_wrapper signature_wrapper::get_commit_committer(const commit_wrapper& cw) +{ + signature_wrapper committer; + committer.p_resource = const_cast(git_commit_committer(cw)); + committer.m_ownership = false; + return committer; +} diff --git a/src/wrapper/signature_wrapper.hpp b/src/wrapper/signature_wrapper.hpp index 150bf62..2ebc861 100644 --- a/src/wrapper/signature_wrapper.hpp +++ b/src/wrapper/signature_wrapper.hpp @@ -1,11 +1,13 @@ #pragma once #include +#include #include #include "../wrapper/wrapper_base.hpp" +class commit_wrapper; class repository_wrapper; class signature_wrapper : public wrapper_base @@ -18,9 +20,16 @@ class signature_wrapper : public wrapper_base signature_wrapper(signature_wrapper&&) = default; signature_wrapper& operator=(signature_wrapper&&) = default; + std::string_view name() const; + std::string_view email() const; + git_time when() const; + static author_committer_signatures get_default_signature_from_env(repository_wrapper&); + static signature_wrapper get_commit_author(const commit_wrapper&); + static signature_wrapper get_commit_committer(const commit_wrapper&); private: signature_wrapper() = default; + bool m_ownership=true; }; diff --git a/test/test_log.py b/test/test_log.py new file mode 100644 index 0000000..659c7a4 --- /dev/null +++ b/test/test_log.py @@ -0,0 +1,54 @@ +import subprocess + +import pytest + +@pytest.mark.parametrize("format_flag", ["", "--format=full", "--format=fuller"]) +def test_log(xtl_clone, git_config, git2cpp_path, tmp_path, monkeypatch, format_flag): + assert (tmp_path / "xtl").exists() + xtl_path = tmp_path / "xtl" + + p = xtl_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) + assert p_add.returncode == 0 + + cmd_commit = [git2cpp_path, 'commit', "-m", "test commit"] + p_commit = subprocess.run(cmd_commit, cwd=xtl_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) + assert p_log.returncode == 0 + assert "Jane Doe" in p_log.stdout + assert "test commit" in p_log.stdout + + if format_flag == "": + assert "Commit" not in p_log.stdout + else: + assert "Commit" in p_log.stdout + if format_flag == "--format=full": + assert "Date" not in p_log.stdout + else: + assert "CommitDate" in p_log.stdout + + +@pytest.mark.parametrize("max_count_flag", ["", "-n", "--max-count"]) +def test_max_count(xtl_clone, git_config, git2cpp_path, tmp_path, monkeypatch, max_count_flag): + assert (tmp_path / "xtl").exists() + xtl_path = tmp_path / "xtl" + + 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) + assert p_log.returncode == 0 + + if max_count_flag == "": + assert p_log.stdout.count("Author") > 2 + else: + assert p_log.stdout.count("Author") == 2 From 541052c4fb2bacf24860efda5730ec29af000cb7 Mon Sep 17 00:00:00 2001 From: Sandrine Pataut Date: Thu, 28 Aug 2025 11:31:07 +0200 Subject: [PATCH 005/116] add reset test (#39) --- src/subcommand/log_subcommand.hpp | 1 - test/test_reset.py | 33 +++++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) create mode 100644 test/test_reset.py diff --git a/src/subcommand/log_subcommand.hpp b/src/subcommand/log_subcommand.hpp index 4a7e0e7..a42c052 100644 --- a/src/subcommand/log_subcommand.hpp +++ b/src/subcommand/log_subcommand.hpp @@ -1,7 +1,6 @@ #pragma once #include -#include #include #include "../utils/common.hpp" diff --git a/test/test_reset.py b/test/test_reset.py new file mode 100644 index 0000000..74e9aa1 --- /dev/null +++ b/test/test_reset.py @@ -0,0 +1,33 @@ +import subprocess + +import pytest + + +def test_reset(xtl_clone, git_config, git2cpp_path, tmp_path, monkeypatch): + assert (tmp_path / "xtl").exists() + xtl_path = tmp_path / "xtl" + + p = xtl_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) + assert p_add.returncode == 0 + + cmd_commit = [git2cpp_path, 'commit', "-m", "test commit"] + p_commit = subprocess.run(cmd_commit, cwd=xtl_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) + assert p_log.returncode == 0 + assert "Jane Doe" 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) + 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 From f928517c24586f4a84afc9eb1eaefc7b99be6743 Mon Sep 17 00:00:00 2001 From: Sandrine Pataut Date: Thu, 28 Aug 2025 11:46:24 +0200 Subject: [PATCH 006/116] Remove useless test/data folder and imports (#40) * remove useless test/data folder * remove usless imports --- test/data/blank_file.txt | 0 test/test_add.py | 1 - test/test_branch.py | 1 - test/test_checkout.py | 1 - test/test_commit.py | 1 - 5 files changed, 4 deletions(-) delete mode 100644 test/data/blank_file.txt diff --git a/test/data/blank_file.txt b/test/data/blank_file.txt deleted file mode 100644 index e69de29..0000000 diff --git a/test/test_add.py b/test/test_add.py index 92c767c..a5055e8 100644 --- a/test/test_add.py +++ b/test/test_add.py @@ -1,4 +1,3 @@ -import os import subprocess import pytest diff --git a/test/test_branch.py b/test/test_branch.py index 0439406..4e1f9fe 100644 --- a/test/test_branch.py +++ b/test/test_branch.py @@ -1,4 +1,3 @@ -import os import subprocess import pytest diff --git a/test/test_checkout.py b/test/test_checkout.py index eb68db0..3243318 100644 --- a/test/test_checkout.py +++ b/test/test_checkout.py @@ -1,4 +1,3 @@ -import os import subprocess import pytest diff --git a/test/test_commit.py b/test/test_commit.py index 932dd02..1ed0018 100644 --- a/test/test_commit.py +++ b/test/test_commit.py @@ -1,4 +1,3 @@ -import os import subprocess import pytest From a930ee2c47d536f382169ddf4e0e45a10b4e9cdd Mon Sep 17 00:00:00 2001 From: Sandrine Pataut Date: Fri, 5 Sep 2025 09:34:48 +0200 Subject: [PATCH 007/116] Add colours to status subcommand (#41) * add colours to status subcommand * address review comment * remove useless comments * switch to use termcolor * fix log colour * remove useless include * remove useleess space --- CMakeLists.txt | 3 +- dev-environment.yml | 1 + src/subcommand/log_subcommand.cpp | 5 +- src/subcommand/status_subcommand.cpp | 94 ++++++++++++++++------------ src/utils/common.cpp | 2 + src/utils/common.hpp | 3 +- 6 files changed, 64 insertions(+), 44 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 2f5acdb..aa4a7ac 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -33,6 +33,7 @@ message(STATUS "Building git2cpp v${CMAKE_PROJECT_VERSION}") # ============ find_package(libgit2) +find_package(termcolor) # CLI11 is a single header, not packaged for cmake # Build @@ -86,4 +87,4 @@ set(GIT2CPP_SRC ) add_executable(git2cpp ${GIT2CPP_SRC}) -target_link_libraries(git2cpp PRIVATE libgit2::libgit2package) +target_link_libraries(git2cpp PRIVATE libgit2::libgit2package termcolor::termcolor) diff --git a/dev-environment.yml b/dev-environment.yml index 04f465e..c578ec0 100644 --- a/dev-environment.yml +++ b/dev-environment.yml @@ -8,5 +8,6 @@ dependencies: - pkg-config - python - pytest + - termcolor-cpp # Missing dependency from libgit2 - zlib diff --git a/src/subcommand/log_subcommand.cpp b/src/subcommand/log_subcommand.cpp index c25e270..3565228 100644 --- a/src/subcommand/log_subcommand.cpp +++ b/src/subcommand/log_subcommand.cpp @@ -4,6 +4,8 @@ #include #include +#include + #include "log_subcommand.hpp" #include "../wrapper/repository_wrapper.hpp" #include "../wrapper/commit_wrapper.hpp" @@ -54,7 +56,8 @@ void print_commit(const commit_wrapper& commit, std::string m_format_flag) signature_wrapper author = signature_wrapper::get_commit_author(commit); signature_wrapper committer = signature_wrapper::get_commit_committer(commit); - std::cout << "\033[0;33m" << "commit " << buf << "\033[0m" << std::endl; + stream_colour_fn colour = termcolor::yellow; + std::cout << colour << "commit " << buf << termcolor::reset << std::endl; if (m_format_flag=="fuller") { std::cout << "Author:\t " << author.name() << " " << author.email() << std::endl; diff --git a/src/subcommand/status_subcommand.cpp b/src/subcommand/status_subcommand.cpp index 83ebd6d..b51f50f 100644 --- a/src/subcommand/status_subcommand.cpp +++ b/src/subcommand/status_subcommand.cpp @@ -4,6 +4,7 @@ #include #include +#include #include "status_subcommand.hpp" #include "../wrapper/status_wrapper.hpp" @@ -45,16 +46,16 @@ struct status_messages const std::map status_msg_map = //TODO : check spaces in short_mod { { GIT_STATUS_CURRENT, {"", ""} }, - { GIT_STATUS_INDEX_NEW, {"A ", "\t new file:"} }, - { GIT_STATUS_INDEX_MODIFIED, {"M ", "\t modified:"} }, - { GIT_STATUS_INDEX_DELETED, {"D ", "\t deleted:"} }, - { GIT_STATUS_INDEX_RENAMED, {"R ", "\t renamed:"} }, - { GIT_STATUS_INDEX_TYPECHANGE, {"T ", "\t typechange:"} }, + { GIT_STATUS_INDEX_NEW, {"A ", "\tnew file:"} }, + { GIT_STATUS_INDEX_MODIFIED, {"M ", "\tmodified:"} }, + { GIT_STATUS_INDEX_DELETED, {"D ", "\tdeleted:"} }, + { GIT_STATUS_INDEX_RENAMED, {"R ", "\trenamed:"} }, + { GIT_STATUS_INDEX_TYPECHANGE, {"T ", "\ttypechange:"} }, { GIT_STATUS_WT_NEW, {"?? ", ""} }, - { GIT_STATUS_WT_MODIFIED, {" M " , "\t modified:"} }, - { GIT_STATUS_WT_DELETED, {" D ", "\t deleted:"} }, - { GIT_STATUS_WT_TYPECHANGE, {" T ", "\t typechange:"} }, - { GIT_STATUS_WT_RENAMED, {" R ", "\t renamed:"} }, + { GIT_STATUS_WT_MODIFIED, {" M " , "\tmodified:"} }, + { GIT_STATUS_WT_DELETED, {" D ", "\tdeleted:"} }, + { GIT_STATUS_WT_TYPECHANGE, {" T ", "\ttypechange:"} }, + { GIT_STATUS_WT_RENAMED, {" R ", "\trenamed:"} }, { GIT_STATUS_WT_UNREADABLE, {"", ""} }, { GIT_STATUS_IGNORED, {"!! ", ""} }, { GIT_STATUS_CONFLICTED, {"", ""} }, @@ -78,7 +79,7 @@ std::string get_print_status(git_status_t status, output_format of) std::string entry_status; if ((of == output_format::DEFAULT) || (of == output_format::LONG)) { - entry_status = status_msg_map.at(status).long_mod + "\t"; + entry_status = status_msg_map.at(status).long_mod + " "; } else if (of == output_format::SHORT) { @@ -139,16 +140,23 @@ std::vector get_entries_to_print(git_status_t status, status_list_w return entries_to_print; } -void print_entries(std::vector entries_to_print) +void print_entries(std::vector entries_to_print, bool is_long, stream_colour_fn colour) { for (auto e: entries_to_print) { - std::cout << e.status << e.item << std::endl; + if (is_long) + { + std::cout << colour << e.status << e.item << termcolor::reset << std::endl; + } + else + { + std::cout << colour << e.status << termcolor::reset << e.item << std::endl; + } } } void print_not_tracked(const std::vector& entries_to_print, const std::set& tracked_dir_set, - std::set& untracked_dir_set) + std::set& untracked_dir_set, bool is_long, stream_colour_fn colour) { std::vector not_tracked_entries_to_print{}; for (auto e: entries_to_print) @@ -156,7 +164,7 @@ void print_not_tracked(const std::vector& entries_to_print, const s const size_t first_slash_idx = e.item.find('/'); if (std::string::npos != first_slash_idx) { - auto directory = e.item.substr(0, first_slash_idx); + auto directory = "\t" + e.item.substr(0, first_slash_idx) + "/"; if (tracked_dir_set.contains(directory)) { not_tracked_entries_to_print.push_back(e); @@ -177,7 +185,7 @@ void print_not_tracked(const std::vector& entries_to_print, const s not_tracked_entries_to_print.push_back(e); } } - print_entries(not_tracked_entries_to_print); + print_entries(not_tracked_entries_to_print, is_long, colour); } void status_subcommand::run() @@ -220,17 +228,19 @@ void status_subcommand::run() std::cout << "## " << branch_name << std::endl; } } + if (sl.has_tobecommited_header()) { + stream_colour_fn colour = termcolor::green; if (is_long) { - std::cout << tobecommited_header << std::endl; + std::cout << tobecommited_header; } - print_entries(get_entries_to_print(GIT_STATUS_INDEX_NEW, sl, true, of, &tracked_dir_set)); - print_entries(get_entries_to_print(GIT_STATUS_INDEX_MODIFIED, sl, true, of, &tracked_dir_set)); - print_entries(get_entries_to_print(GIT_STATUS_INDEX_DELETED, sl, true, of, &tracked_dir_set)); - print_entries(get_entries_to_print(GIT_STATUS_INDEX_RENAMED, sl, true, of, &tracked_dir_set)); - print_entries(get_entries_to_print(GIT_STATUS_INDEX_TYPECHANGE, sl, true, of, &tracked_dir_set)); + 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; @@ -239,44 +249,46 @@ void status_subcommand::run() if (sl.has_notstagged_header()) { + stream_colour_fn colour = termcolor::red; if (is_long) { - std::cout << notstagged_header << std::endl; + std::cout << notstagged_header; } - print_entries(get_entries_to_print(GIT_STATUS_WT_MODIFIED, sl, false, of, &tracked_dir_set)); - print_entries(get_entries_to_print(GIT_STATUS_WT_DELETED, sl, false, of, &tracked_dir_set)); - print_entries(get_entries_to_print(GIT_STATUS_WT_TYPECHANGE, sl, false, of, &tracked_dir_set)); - print_entries(get_entries_to_print(GIT_STATUS_WT_RENAMED, sl, false, of, &tracked_dir_set)); + 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; } } - if (sl.has_untracked_header()) { + stream_colour_fn colour = termcolor::red; if (is_long) { - std::cout << untracked_header << std::endl; + std::cout << untracked_header; } - print_not_tracked(get_entries_to_print(GIT_STATUS_WT_NEW, sl, false, of), tracked_dir_set, untracked_dir_set); + 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; } } - if (sl.has_ignored_header()) - { - if (is_long) - { - std::cout << ignored_header << std::endl; - } - print_not_tracked(get_entries_to_print(GIT_STATUS_IGNORED, sl, false, of), tracked_dir_set, untracked_dir_set); - if (is_long) - { - std::cout << std::endl; - } - } + // if (sl.has_ignored_header()) + // { + // stream_colour_fn colour = termcolor::red; + // if (is_long) + // { + // std::cout << ignored_header; + // } + // print_not_tracked(get_entries_to_print(GIT_STATUS_IGNORED, sl, false, of), tracked_dir_set, untracked_dir_set, is_long, colour); + // if (is_long) + // { + // std::cout << std::endl; + // } + // } } diff --git a/src/utils/common.cpp b/src/utils/common.cpp index e5d97f4..9a5787c 100644 --- a/src/utils/common.cpp +++ b/src/utils/common.cpp @@ -1,4 +1,6 @@ #include +#include +#include #include diff --git a/src/utils/common.hpp b/src/utils/common.hpp index 44667d8..e3b959c 100644 --- a/src/utils/common.hpp +++ b/src/utils/common.hpp @@ -1,7 +1,6 @@ #pragma once #include -#include #include #include @@ -29,6 +28,8 @@ class libgit2_object : private noncopyable_nonmovable std::string get_current_git_path(); +using stream_colour_fn = std::ostream& (*)(std::ostream&); + class git_strarray_wrapper { public: From c9d63312231be4f5d9fa050a10b3e40ee020d2c9 Mon Sep 17 00:00:00 2001 From: Johan Mabille Date: Fri, 5 Sep 2025 11:34:26 +0200 Subject: [PATCH 008/116] Fixed most of commands creating a git repo instead of throwing an error (#42) * Fixed most of commands creating a git repo instead of throwing an error * Removed remaining test/data files --- src/subcommand/add_subcommand.cpp | 3 +-- src/subcommand/commit_subcommand.cpp | 3 +-- src/subcommand/log_subcommand.cpp | 3 +-- src/subcommand/reset_subcommand.cpp | 3 +-- src/subcommand/status_subcommand.cpp | 3 +-- test/test_add.py | 8 ++++++++ test/test_branch.py | 5 +++++ test/test_log.py | 5 +++++ test/test_reset.py | 6 ++++++ test/test_status.py | 4 ++++ 10 files changed, 33 insertions(+), 10 deletions(-) diff --git a/src/subcommand/add_subcommand.cpp b/src/subcommand/add_subcommand.cpp index 9988d25..51f57c1 100644 --- a/src/subcommand/add_subcommand.cpp +++ b/src/subcommand/add_subcommand.cpp @@ -23,8 +23,7 @@ add_subcommand::add_subcommand(const libgit2_object&, CLI::App& app) void add_subcommand::run() { auto directory = get_current_git_path(); - auto bare = false; - auto repo = repository_wrapper::init(directory, bare); + auto repo = repository_wrapper::open(directory); index_wrapper index = repo.make_index(); diff --git a/src/subcommand/commit_subcommand.cpp b/src/subcommand/commit_subcommand.cpp index e70f424..d281ec6 100644 --- a/src/subcommand/commit_subcommand.cpp +++ b/src/subcommand/commit_subcommand.cpp @@ -19,8 +19,7 @@ commit_subcommand::commit_subcommand(const libgit2_object&, CLI::App& app) void commit_subcommand::run() { auto directory = get_current_git_path(); - auto bare = false; - auto repo = repository_wrapper::init(directory, bare); + auto repo = repository_wrapper::open(directory); auto author_committer_signatures = signature_wrapper::get_default_signature_from_env(repo); if (m_commit_message.empty()) diff --git a/src/subcommand/log_subcommand.cpp b/src/subcommand/log_subcommand.cpp index 3565228..3fa1746 100644 --- a/src/subcommand/log_subcommand.cpp +++ b/src/subcommand/log_subcommand.cpp @@ -83,8 +83,7 @@ void print_commit(const commit_wrapper& commit, std::string m_format_flag) void log_subcommand::run() { auto directory = get_current_git_path(); - auto bare = false; - auto repo = repository_wrapper::init(directory, bare); + auto repo = repository_wrapper::open(directory); // auto branch_name = repo.head().short_name(); git_revwalk* walker; diff --git a/src/subcommand/reset_subcommand.cpp b/src/subcommand/reset_subcommand.cpp index b862e78..61fa0ae 100644 --- a/src/subcommand/reset_subcommand.cpp +++ b/src/subcommand/reset_subcommand.cpp @@ -27,8 +27,7 @@ reset_subcommand::reset_subcommand(const libgit2_object&, CLI::App& app) void reset_subcommand::run() { auto directory = get_current_git_path(); - auto bare = false; - auto repo = repository_wrapper::init(directory, bare); + auto repo = repository_wrapper::open(directory); auto target = repo.revparse_single(m_commit); if (!target) diff --git a/src/subcommand/status_subcommand.cpp b/src/subcommand/status_subcommand.cpp index b51f50f..c24a3e2 100644 --- a/src/subcommand/status_subcommand.cpp +++ b/src/subcommand/status_subcommand.cpp @@ -191,8 +191,7 @@ void print_not_tracked(const std::vector& entries_to_print, const s void status_subcommand::run() { auto directory = get_current_git_path(); - auto bare = false; - auto repo = repository_wrapper::init(directory, bare); + auto repo = repository_wrapper::open(directory); auto sl = status_list_wrapper::status_list(repo); auto branch_name = repo.head().short_name(); diff --git a/test/test_add.py b/test/test_add.py index a5055e8..a772779 100644 --- a/test/test_add.py +++ b/test/test_add.py @@ -32,3 +32,11 @@ def test_add(xtl_clone, git2cpp_path, tmp_path, all_flag): assert "Untracked files" not in p_status.stdout 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('') + + cmd_add = [git2cpp_path, 'add', 'mook_file.txt'] + p_add = subprocess.run(cmd_add, cwd=tmp_path, text=True) + assert p_add.returncode != 0 diff --git a/test/test_branch.py b/test/test_branch.py index 4e1f9fe..f81bc8c 100644 --- a/test/test_branch.py +++ b/test/test_branch.py @@ -33,3 +33,8 @@ def test_branch_create_delete(xtl_clone, git2cpp_path, tmp_path): p_list2 = subprocess.run(list_cmd, capture_output=True, cwd=xtl_path, text=True) assert p_list2.returncode == 0 assert(p_list2.stdout == '* master\n') + +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 diff --git a/test/test_log.py b/test/test_log.py index 659c7a4..d7dae3d 100644 --- a/test/test_log.py +++ b/test/test_log.py @@ -35,6 +35,11 @@ def test_log(xtl_clone, git_config, git2cpp_path, tmp_path, monkeypatch, format_ else: assert "CommitDate" in p_log.stdout +def test_log_nogit(git_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 + @pytest.mark.parametrize("max_count_flag", ["", "-n", "--max-count"]) def test_max_count(xtl_clone, git_config, git2cpp_path, tmp_path, monkeypatch, max_count_flag): diff --git a/test/test_reset.py b/test/test_reset.py index 74e9aa1..3b86a1d 100644 --- a/test/test_reset.py +++ b/test/test_reset.py @@ -31,3 +31,9 @@ def test_reset(xtl_clone, git_config, git2cpp_path, tmp_path, monkeypatch): 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 + +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 + diff --git a/test/test_status.py b/test/test_status.py index cfacb7b..ab7288e 100644 --- a/test/test_status.py +++ b/test/test_status.py @@ -38,6 +38,10 @@ def test_status_new_file(xtl_clone, git2cpp_path, tmp_path, short_flag, long_fla assert " D " in p.stdout assert "?? " in p.stdout +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 @pytest.mark.parametrize("short_flag", ["", "-s", "--short"]) @pytest.mark.parametrize("long_flag", ["", "--long"]) From fe44bbda3497d5269ac7b5acf03e005e4172a538 Mon Sep 17 00:00:00 2001 From: Sandrine Pataut Date: Mon, 8 Sep 2025 16:35:57 +0200 Subject: [PATCH 009/116] update release version (#43) --- src/version.hpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/version.hpp b/src/version.hpp index 8a5b5b5..cb8064c 100644 --- a/src/version.hpp +++ b/src/version.hpp @@ -2,7 +2,7 @@ #define GIT2CPP_VERSION_MAJOR 0 #define GIT2CPP_VERSION_MINOR 0 -#define GIT2CPP_VERSION_PATCH 3 +#define GIT2CPP_VERSION_PATCH 4 // e.g. ".rc0" #define GIT2CPP_VERSION_SUFFIX From bd07ea7c062057608f872d9f256ac12a7be3ca55 Mon Sep 17 00:00:00 2001 From: Johan Mabille Date: Fri, 17 Oct 2025 09:56:20 +0200 Subject: [PATCH 010/116] Implemented template metod peel (#47) * Implemented template metod peel * Typo negative --- src/wrapper/commit_wrapper.hpp | 1 + src/wrapper/object_wrapper.hpp | 1 + src/wrapper/refs_wrapper.hpp | 30 ++++++++++++++++++++++++++++++ 3 files changed, 32 insertions(+) diff --git a/src/wrapper/commit_wrapper.hpp b/src/wrapper/commit_wrapper.hpp index d7930d9..0560007 100644 --- a/src/wrapper/commit_wrapper.hpp +++ b/src/wrapper/commit_wrapper.hpp @@ -26,4 +26,5 @@ class commit_wrapper : public wrapper_base commit_wrapper(git_commit* commit); friend class repository_wrapper; + friend class reference_wrapper; }; diff --git a/src/wrapper/object_wrapper.hpp b/src/wrapper/object_wrapper.hpp index bceab14..d839ade 100644 --- a/src/wrapper/object_wrapper.hpp +++ b/src/wrapper/object_wrapper.hpp @@ -24,4 +24,5 @@ class object_wrapper : public wrapper_base object_wrapper(git_object* obj); friend class repository_wrapper; + friend class reference_wrapper; }; diff --git a/src/wrapper/refs_wrapper.hpp b/src/wrapper/refs_wrapper.hpp index 2ad4b21..e7509c2 100644 --- a/src/wrapper/refs_wrapper.hpp +++ b/src/wrapper/refs_wrapper.hpp @@ -1,5 +1,6 @@ #pragma once +#include #include #include @@ -20,9 +21,38 @@ class reference_wrapper : public wrapper_base std::string short_name() const; bool is_remote() const; + template + W peel() const; + private: reference_wrapper(git_reference* ref); friend class repository_wrapper; }; + +class commit_wrapper; +class object_wrapper; + +// TODO: add constraints on W +// For now it accepts commit_wrapper and object_wrapper only +template +W reference_wrapper::peel() const +{ + constexpr git_object_t obj_type = [] + { + if constexpr (std::same_as) + { + return GIT_OBJECT_COMMIT; + } + else // Default case + { + return GIT_OBJECT_ANY; + } + }(); + + using resource_type = typename W::resource_type; + git_object* resource = nullptr; + throw_if_error(git_reference_peel(&resource, this->p_resource, obj_type)); + return W(reinterpret_cast(resource)); +} From e544bc43a1991c6856a25ae75c2ebb907b46f9b7 Mon Sep 17 00:00:00 2001 From: Ian Thomas Date: Mon, 20 Oct 2025 13:58:53 +0100 Subject: [PATCH 011/116] Implement terminal_pager for log subcommand (#46) * Implement terminal_pager for log subcommand * Include cstdint * Use stringbuf instead of ostringstream * Remove m_grabbed * Pass by value to process_input * Separate namespace for ANSI code sequences to avoid magic strings * alternative_buffer scope object --- CMakeLists.txt | 5 + src/subcommand/log_subcommand.cpp | 5 + src/utils/ansi_code.cpp | 24 ++++ src/utils/ansi_code.hpp | 29 ++++ src/utils/output.cpp | 23 ++++ src/utils/output.hpp | 21 ++- src/utils/terminal_pager.cpp | 221 ++++++++++++++++++++++++++++++ src/utils/terminal_pager.hpp | 62 +++++++++ 8 files changed, 388 insertions(+), 2 deletions(-) create mode 100644 src/utils/ansi_code.cpp create mode 100644 src/utils/ansi_code.hpp create mode 100644 src/utils/output.cpp create mode 100644 src/utils/terminal_pager.cpp create mode 100644 src/utils/terminal_pager.hpp diff --git a/CMakeLists.txt b/CMakeLists.txt index aa4a7ac..ba71a22 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -58,11 +58,16 @@ set(GIT2CPP_SRC ${GIT2CPP_SOURCE_DIR}/subcommand/reset_subcommand.hpp ${GIT2CPP_SOURCE_DIR}/subcommand/status_subcommand.cpp ${GIT2CPP_SOURCE_DIR}/subcommand/status_subcommand.hpp + ${GIT2CPP_SOURCE_DIR}/utils/ansi_code.cpp + ${GIT2CPP_SOURCE_DIR}/utils/ansi_code.hpp ${GIT2CPP_SOURCE_DIR}/utils/common.cpp ${GIT2CPP_SOURCE_DIR}/utils/common.hpp ${GIT2CPP_SOURCE_DIR}/utils/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/terminal_pager.cpp + ${GIT2CPP_SOURCE_DIR}/utils/terminal_pager.hpp ${GIT2CPP_SOURCE_DIR}/wrapper/annotated_commit_wrapper.cpp ${GIT2CPP_SOURCE_DIR}/wrapper/annotated_commit_wrapper.hpp ${GIT2CPP_SOURCE_DIR}/wrapper/branch_wrapper.cpp diff --git a/src/subcommand/log_subcommand.cpp b/src/subcommand/log_subcommand.cpp index 3fa1746..c295c7f 100644 --- a/src/subcommand/log_subcommand.cpp +++ b/src/subcommand/log_subcommand.cpp @@ -7,6 +7,7 @@ #include #include "log_subcommand.hpp" +#include "../utils/terminal_pager.hpp" #include "../wrapper/repository_wrapper.hpp" #include "../wrapper/commit_wrapper.hpp" @@ -90,6 +91,8 @@ void log_subcommand::run() git_revwalk_new(&walker, repo); git_revwalk_push_head(walker); + terminal_pager pager; + std::size_t i=0; git_oid commit_oid; while (!git_revwalk_next(&commit_oid, walker) && i + +/** + * ANSI escape codes. + * Use `termcolor` for colours. + */ +namespace ansi_code +{ + // Constants. + const std::string bel = "\a"; // ASCII 7, used for audio/visual feedback. + const std::string cursor_to_top = "\e[H"; + const std::string erase_screen = "\e[2J"; + + const std::string enable_alternative_buffer = "\e[?1049h"; + const std::string disable_alternative_buffer = "\e[?1049l"; + + const std::string hide_cursor = "\e[?25l"; + const std::string show_cursor = "\e[?25h"; + + // Functions. + std::string cursor_to_row(size_t row); + + bool is_escape_char(char ch); + + bool is_down_arrow(std::string str); + bool is_up_arrow(std::string str); +} diff --git a/src/utils/output.cpp b/src/utils/output.cpp new file mode 100644 index 0000000..71584b1 --- /dev/null +++ b/src/utils/output.cpp @@ -0,0 +1,23 @@ +#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 index 173c2a5..803c20d 100644 --- a/src/utils/output.hpp +++ b/src/utils/output.hpp @@ -1,8 +1,12 @@ #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. @@ -10,11 +14,24 @@ struct cursor_hider : noncopyable_nonmovable { cursor_hider() { - std::cout << "\e[?25l"; + std::cout << ansi_code::hide_cursor; } ~cursor_hider() { - std::cout << "\e[?25h"; + 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 new file mode 100644 index 0000000..e7fe551 --- /dev/null +++ b/src/utils/terminal_pager.cpp @@ -0,0 +1,221 @@ +#include +#include +#include +#include +#include + +// OS-specific libraries. +#include + +#include + +#include "ansi_code.hpp" +#include "output.hpp" +#include "terminal_pager.hpp" + +terminal_pager::terminal_pager() + : m_rows(0), m_columns(0), m_start_row_index(0) +{ + maybe_grab_cout(); +} + +terminal_pager::~terminal_pager() +{ + release_cout(); +} + +std::string terminal_pager::get_input() const +{ + // Blocks until input received. + std::string str; + char ch; + std::cin.get(ch); + str += ch; + + if (ansi_code::is_escape_char(ch)) // Start of ANSI escape sequence. + { + do + { + std::cin.get(ch); + str += ch; + } while (!std::isalpha(ch)); // ANSI escape sequence ends with a letter. + } + + return str; +} + +void terminal_pager::maybe_grab_cout() +{ + // Unfortunately need to access _internal namespace of termcolor to check if a tty. + if (termcolor::_internal::is_atty(std::cout)) + { + // Should we do anything with cerr? + m_cout_rdbuf = std::cout.rdbuf(&m_stringbuf); + } + else + { + m_cout_rdbuf = std::cout.rdbuf(); + } +} + +bool terminal_pager::process_input(std::string input) +{ + if (input.size() == 0) + { + return true; + } + + switch (input[0]) + { + case 'q': + case 'Q': + return true; // Exit pager. + case 'u': + case 'U': + scroll(true, true); // Up a page. + return false; + case 'd': + case 'D': + case ' ': + scroll(false, true); // Down a page. + return false; + case '\n': + scroll(false, false); // Down a line. + return false; + case '\e': // ANSI escape sequence. + // Cannot switch on a std::string. + if (ansi_code::is_up_arrow(input)) + { + scroll(true, false); // Up a line. + return false; + } + else if (ansi_code::is_down_arrow(input)) + { + scroll(false, false); // Down a line. + return false; + } + } + + std::cout << ansi_code::bel; + return false; +} + +void terminal_pager::release_cout() +{ + std::cout.rdbuf(m_cout_rdbuf); +} + +void terminal_pager::render_terminal() const +{ + auto end_row_index = m_start_row_index + m_rows - 1; + + std::cout << ansi_code::erase_screen; + std::cout << ansi_code::cursor_to_top; + + for (size_t i = m_start_row_index; i < end_row_index; i++) + { + if (i >= m_lines.size()) + { + break; + } + std::cout << m_lines[i] << std::endl; + } + + std::cout << ansi_code::cursor_to_row(m_rows); // Move cursor to bottom row of terminal. + std::cout << ":"; +} + +void terminal_pager::scroll(bool up, bool page) +{ + update_terminal_size(); + const auto old_start_row_index = m_start_row_index; + size_t offset = page ? m_rows - 1 : 1; + + if (up) + { + // Care needed to avoid underflow of unsigned size_t. + if (m_start_row_index >= offset) + { + m_start_row_index -= offset; + } + else + { + m_start_row_index = 0; + } + } + else + { + m_start_row_index += offset; + auto end_row_index = m_start_row_index + m_rows - 1; + if (end_row_index > m_lines.size()) + { + m_start_row_index = m_lines.size() - (m_rows - 1); + } + } + + if (m_start_row_index == old_start_row_index) + { + std::cout << ansi_code::bel; + } + else + { + render_terminal(); + } +} + +void terminal_pager::show() +{ + release_cout(); + + split_input_at_newlines(m_stringbuf.view()); + + update_terminal_size(); + if (m_rows == 0 || m_lines.size() <= m_rows - 1) + { + // Don't need to use pager, can display directly. + for (auto line : m_lines) + { + std::cout << line << std::endl; + } + m_lines.clear(); + return; + } + + alternative_buffer alt_buffer; + + m_start_row_index = 0; + render_terminal(); + + bool stop = false; + do + { + stop = process_input(get_input()); + } while (!stop); + + m_lines.clear(); + 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(), std::ranges::distance(range)); + }); + m_lines = std::vector{split.begin(), split.end()}; +} + +void terminal_pager::update_terminal_size() +{ + struct winsize size; + int err = ioctl(fileno(stdout), TIOCGWINSZ, &size); + if (err == 0) + { + m_rows = size.ws_row; + m_columns = size.ws_col; + } + else + { + m_rows = m_columns = 0; + } +} diff --git a/src/utils/terminal_pager.hpp b/src/utils/terminal_pager.hpp new file mode 100644 index 0000000..8c710a1 --- /dev/null +++ b/src/utils/terminal_pager.hpp @@ -0,0 +1,62 @@ +#pragma once + +#include +#include + +/** + * Terminal pager that displays output written to stdout one page at a time, allowing the user to + * interactively scroll up and down. If cout is not a tty or the output is shorter than a single + * terminal page it does nothing. + * + * It expects all of cout to be written before the first page is displayed, so it does not pipe from + * cout which would be a more complicated implementation allowing the first page to be displayed + * before all of the output is written. This may need to be reconsidered if we need more performant + * handling of slow subcommands such as `git2cpp log` of repos with long histories. + * + * Keys handled: + * d, space scroll down a page + * u scroll up a page + * q quit pager + * down arrow, enter, return scroll down a line + * up arrow scroll up a line + * + * Emits a BEL (ASCII 7) for unrecognised keys or attempts to scroll too far, which is used by some + * terminals for visual and/or audible feedback. + * + * Does not respond to a change of terminal size whilst it is waiting for input, but it will the + * next time the output is scrolled. + */ +class terminal_pager +{ +public: + terminal_pager(); + + ~terminal_pager(); + + void show(); + +private: + std::string get_input() const; + + void maybe_grab_cout(); + + // Return true if should stop pager. + bool process_input(std::string input); + + void release_cout(); + + void render_terminal() const; + + void scroll(bool up, bool page); + + void split_input_at_newlines(std::string_view str); + + void update_terminal_size(); + + + std::stringbuf m_stringbuf; + std::streambuf* m_cout_rdbuf; + std::vector m_lines; + size_t m_rows, m_columns; + size_t m_start_row_index; +}; From 69b86a46b4df6402acc7fb0fd47f91ff78c666bf Mon Sep 17 00:00:00 2001 From: Sandrine Pataut Date: Thu, 30 Oct 2025 10:28:35 +0100 Subject: [PATCH 012/116] Add ```merge``` subcommand (fastforward) (#48) * Add merge subcommand (fastforward) * comment unused code * clean code * address review comments --- CMakeLists.txt | 2 + src/main.cpp | 3 + src/subcommand/checkout_subcommand.cpp | 22 +---- src/subcommand/checkout_subcommand.hpp | 6 -- src/subcommand/commit_subcommand.cpp | 2 +- src/subcommand/merge_subcommand.cpp | 100 +++++++++++++++++++++++ src/subcommand/merge_subcommand.hpp | 20 +++++ src/wrapper/annotated_commit_wrapper.hpp | 1 + src/wrapper/commit_wrapper.hpp | 5 +- src/wrapper/refs_wrapper.cpp | 15 ++++ src/wrapper/refs_wrapper.hpp | 4 + src/wrapper/repository_wrapper.cpp | 82 +++++++++++++++++-- src/wrapper/repository_wrapper.hpp | 7 +- src/wrapper/signature_wrapper.cpp | 21 +++++ src/wrapper/signature_wrapper.hpp | 13 ++- src/wrapper/wrapper_base.hpp | 44 ++++++++++ test/conftest.py | 15 ++-- test/test_merge.py | 46 +++++++++++ 18 files changed, 361 insertions(+), 47 deletions(-) create mode 100644 src/subcommand/merge_subcommand.cpp create mode 100644 src/subcommand/merge_subcommand.hpp create mode 100644 test/test_merge.py diff --git a/CMakeLists.txt b/CMakeLists.txt index ba71a22..d18cbd9 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -54,6 +54,8 @@ set(GIT2CPP_SRC ${GIT2CPP_SOURCE_DIR}/subcommand/init_subcommand.hpp ${GIT2CPP_SOURCE_DIR}/subcommand/log_subcommand.cpp ${GIT2CPP_SOURCE_DIR}/subcommand/log_subcommand.hpp + ${GIT2CPP_SOURCE_DIR}/subcommand/merge_subcommand.cpp + ${GIT2CPP_SOURCE_DIR}/subcommand/merge_subcommand.hpp ${GIT2CPP_SOURCE_DIR}/subcommand/reset_subcommand.cpp ${GIT2CPP_SOURCE_DIR}/subcommand/reset_subcommand.hpp ${GIT2CPP_SOURCE_DIR}/subcommand/status_subcommand.cpp diff --git a/src/main.cpp b/src/main.cpp index 71140ad..e8479c8 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,4 +1,5 @@ #include +#include #include // For version number only #include @@ -11,6 +12,7 @@ #include "subcommand/commit_subcommand.hpp" #include "subcommand/init_subcommand.hpp" #include "subcommand/log_subcommand.hpp" +#include "subcommand/merge_subcommand.hpp" #include "subcommand/reset_subcommand.hpp" #include "subcommand/status_subcommand.hpp" @@ -35,6 +37,7 @@ int main(int argc, char** argv) commit_subcommand commit(lg2_obj, app); reset_subcommand reset(lg2_obj, app); log_subcommand log(lg2_obj, app); + merge_subcommand merge(lg2_obj, app); app.require_subcommand(/* min */ 0, /* max */ 1); diff --git a/src/subcommand/checkout_subcommand.cpp b/src/subcommand/checkout_subcommand.cpp index 09491b8..d0103d7 100644 --- a/src/subcommand/checkout_subcommand.cpp +++ b/src/subcommand/checkout_subcommand.cpp @@ -43,7 +43,7 @@ void checkout_subcommand::run() } else { - auto optional_commit = resolve_local_ref(repo, m_branch_name); + auto optional_commit = repo.resolve_local_ref(m_branch_name); if (!optional_commit) { // TODO: handle remote refs @@ -56,26 +56,6 @@ void checkout_subcommand::run() } } -std::optional checkout_subcommand::resolve_local_ref -( - const repository_wrapper& repo, - const std::string& target_name -) -{ - if (auto ref = repo.find_reference_dwim(target_name)) - { - return repo.find_annotated_commit(*ref); - } - else if (auto obj = repo.revparse_single(target_name)) - { - return repo.find_annotated_commit(obj->oid()); - } - else - { - return std::nullopt; - } -} - annotated_commit_wrapper checkout_subcommand::create_local_branch ( repository_wrapper& repo, diff --git a/src/subcommand/checkout_subcommand.hpp b/src/subcommand/checkout_subcommand.hpp index 533b46a..2aab79e 100644 --- a/src/subcommand/checkout_subcommand.hpp +++ b/src/subcommand/checkout_subcommand.hpp @@ -17,12 +17,6 @@ class checkout_subcommand private: - std::optional resolve_local_ref - ( - const repository_wrapper& repo, - const std::string& target_name - ); - annotated_commit_wrapper create_local_branch ( repository_wrapper& repo, diff --git a/src/subcommand/commit_subcommand.cpp b/src/subcommand/commit_subcommand.cpp index d281ec6..bca7f39 100644 --- a/src/subcommand/commit_subcommand.cpp +++ b/src/subcommand/commit_subcommand.cpp @@ -32,5 +32,5 @@ void commit_subcommand::run() } } - repo.create_commit(author_committer_signatures, m_commit_message); + repo.create_commit(author_committer_signatures, m_commit_message, std::nullopt); } diff --git a/src/subcommand/merge_subcommand.cpp b/src/subcommand/merge_subcommand.cpp new file mode 100644 index 0000000..a9e62be --- /dev/null +++ b/src/subcommand/merge_subcommand.cpp @@ -0,0 +1,100 @@ +#include +#include + +#include "merge_subcommand.hpp" +// #include "../wrapper/repository_wrapper.hpp" + + +merge_subcommand::merge_subcommand(const libgit2_object&, CLI::App& app) +{ + auto *sub = app.add_subcommand("merge", "Join two or more development histories together"); + + sub->add_option("", m_branches_to_merge, "Branch(es) to merge"); + + sub->callback([this]() { this->run(); }); +} + +annotated_commit_list_wrapper merge_subcommand::resolve_heads(const repository_wrapper& repo) +{ + std::vector commits_to_merge; + commits_to_merge.reserve(m_branches_to_merge.size()); + + for (const auto branch_name:m_branches_to_merge) + { + std::optional commit = repo.resolve_local_ref(branch_name); + if (commit.has_value()) + { + commits_to_merge.push_back(std::move(commit).value()); + } + } + return annotated_commit_list_wrapper(std::move(commits_to_merge)); +} + +void perform_fastforward(repository_wrapper& repo, const git_oid target_oid, int is_unborn) +{ + const git_checkout_options ff_checkout_options = GIT_CHECKOUT_OPTIONS_INIT; + + auto lambda_get_target_ref = [] (auto repo, auto is_unborn) + { + if (!is_unborn) + { + return repo->head(); + } + else + { + return repo->find_reference("HEAD"); + } + }; + reference_wrapper target_ref = lambda_get_target_ref(&repo, is_unborn); + + object_wrapper target = repo.find_object(target_oid, GIT_OBJECT_COMMIT); + + repo.checkout_tree(target, ff_checkout_options); + + target_ref.write_new_ref(target_oid); +} + +void merge_subcommand::run() +{ + auto directory = get_current_git_path(); + auto bare = false; + auto repo = repository_wrapper::open(directory); + + auto state = repo.state(); + if (state != GIT_REPOSITORY_STATE_NONE) + { + std::cout << "repository is in unexpected state " << state <(c_commits_to_merge); + + throw_if_error(git_merge_analysis(&analysis, &preference, repo, commits_to_merge_const, num_commits_to_merge)); + + if (analysis & GIT_MERGE_ANALYSIS_UP_TO_DATE) + { + std::cout << "Already up-to-date" << std::endl; + } + else if (analysis & GIT_MERGE_ANALYSIS_UNBORN || + (analysis & GIT_MERGE_ANALYSIS_FASTFORWARD && + !(preference & GIT_MERGE_PREFERENCE_NO_FASTFORWARD))) + { + if (analysis & GIT_MERGE_ANALYSIS_UNBORN) + { + std::cout << "Unborn" << std::endl; + } + else + { + std::cout << "Fast-forward" << std::endl; + } + const annotated_commit_wrapper& commit = commits_to_merge.front(); + const git_oid target_oid = commit.oid(); + // Since this is a fast-forward, there can be only one merge head. + assert(num_commits_to_merge == 1); + perform_fastforward(repo, target_oid, (analysis & GIT_MERGE_ANALYSIS_UNBORN)); + } +} diff --git a/src/subcommand/merge_subcommand.hpp b/src/subcommand/merge_subcommand.hpp new file mode 100644 index 0000000..3d73f47 --- /dev/null +++ b/src/subcommand/merge_subcommand.hpp @@ -0,0 +1,20 @@ +#pragma once + +#include + +#include "../utils/common.hpp" +#include "../wrapper/repository_wrapper.hpp" + +class merge_subcommand +{ +public: + + explicit merge_subcommand(const libgit2_object&, CLI::App& app); + void run(); + +private: + + annotated_commit_list_wrapper resolve_heads(const repository_wrapper& repo); + + std::vector m_branches_to_merge; +}; diff --git a/src/wrapper/annotated_commit_wrapper.hpp b/src/wrapper/annotated_commit_wrapper.hpp index 7bb5a4c..c390e2f 100644 --- a/src/wrapper/annotated_commit_wrapper.hpp +++ b/src/wrapper/annotated_commit_wrapper.hpp @@ -27,3 +27,4 @@ class annotated_commit_wrapper : public wrapper_base friend class repository_wrapper; }; +using annotated_commit_list_wrapper = list_wrapper; diff --git a/src/wrapper/commit_wrapper.hpp b/src/wrapper/commit_wrapper.hpp index 0560007..1327beb 100644 --- a/src/wrapper/commit_wrapper.hpp +++ b/src/wrapper/commit_wrapper.hpp @@ -1,8 +1,9 @@ #pragma once #include +#include +#include -#include "../wrapper/repository_wrapper.hpp" #include "../wrapper/wrapper_base.hpp" class commit_wrapper : public wrapper_base @@ -28,3 +29,5 @@ class commit_wrapper : public wrapper_base friend class repository_wrapper; friend class reference_wrapper; }; + +using commit_list_wrapper = list_wrapper; diff --git a/src/wrapper/refs_wrapper.cpp b/src/wrapper/refs_wrapper.cpp index 571ca52..4af5906 100644 --- a/src/wrapper/refs_wrapper.cpp +++ b/src/wrapper/refs_wrapper.cpp @@ -1,4 +1,7 @@ #include "../utils/git_exception.hpp" +#include "object_wrapper.hpp" +#include +#include #include "../wrapper/refs_wrapper.hpp" reference_wrapper::reference_wrapper(git_reference* ref) @@ -21,3 +24,15 @@ bool reference_wrapper::is_remote() const { return git_reference_is_remote(*this); } + +const git_oid* reference_wrapper::target() const +{ + return git_reference_target(p_resource); +} + +reference_wrapper reference_wrapper::write_new_ref(const git_oid target_oid) +{ + git_reference* new_ref; + throw_if_error(git_reference_set_target(&new_ref, p_resource, &target_oid, NULL)); + return reference_wrapper(new_ref); +} diff --git a/src/wrapper/refs_wrapper.hpp b/src/wrapper/refs_wrapper.hpp index e7509c2..d7645ca 100644 --- a/src/wrapper/refs_wrapper.hpp +++ b/src/wrapper/refs_wrapper.hpp @@ -5,7 +5,9 @@ #include +#include "../utils/git_exception.hpp" #include "../wrapper/wrapper_base.hpp" +#include "../wrapper/object_wrapper.hpp" class reference_wrapper : public wrapper_base { @@ -20,6 +22,8 @@ class reference_wrapper : public wrapper_base std::string short_name() const; bool is_remote() const; + const git_oid* target() const; + reference_wrapper write_new_ref(const git_oid target_oid); template W peel() const; diff --git a/src/wrapper/repository_wrapper.cpp b/src/wrapper/repository_wrapper.cpp index 6eb00f4..d099382 100644 --- a/src/wrapper/repository_wrapper.cpp +++ b/src/wrapper/repository_wrapper.cpp @@ -1,6 +1,7 @@ #include "../utils/git_exception.hpp" #include "../wrapper/index_wrapper.hpp" #include "../wrapper/object_wrapper.hpp" +#include "../wrapper/commit_wrapper.hpp" #include "../wrapper/repository_wrapper.hpp" repository_wrapper::~repository_wrapper() @@ -35,6 +36,8 @@ git_repository_state_t repository_wrapper::state() const return git_repository_state_t(git_repository_state(*this)); } +// References + reference_wrapper repository_wrapper::head() const { git_reference* ref; @@ -56,12 +59,16 @@ std::optional repository_wrapper::find_reference_dwim(std::st return rc == 0 ? std::make_optional(reference_wrapper(ref)) : std::nullopt; } +// Index + index_wrapper repository_wrapper::make_index() { index_wrapper index = index_wrapper::init(*this); return index; } +// Branches + branch_wrapper repository_wrapper::create_branch(std::string_view name, bool force) { return create_branch(name, find_commit(), force); @@ -95,6 +102,8 @@ branch_iterator repository_wrapper::iterate_branches(git_branch_t type) const return branch_iterator(iter); } +// Commits + commit_wrapper repository_wrapper::find_commit(std::string_view ref_name) const { git_oid oid_parent_commit; @@ -110,20 +119,36 @@ commit_wrapper repository_wrapper::find_commit(const git_oid& id) const } void repository_wrapper::create_commit(const signature_wrapper::author_committer_signatures& author_committer_signatures, - const std::string& message) + const std::string_view message, const std::optional& parents_list) { const char* message_encoding = "UTF-8"; git_oid commit_id; std::string update_ref = "HEAD"; - auto parent = revparse_single(update_ref); - std::size_t parent_count = 0; - const git_commit* parents[1] = {nullptr}; - if (parent) + const git_commit* placeholder[1] = {nullptr}; + + auto [parents, parents_count] = [&]() -> std::pair { - parent_count = 1; - parents[0] = *parent; - } + if (parents_list) + { + // TODO: write a "as_const" function to replace the following + auto pl_size = parents_list.value().size(); + git_commit** pl_value = parents_list.value(); + auto pl_value_const = const_cast(pl_value); + return {pl_value_const, pl_size}; + } + else + { + auto parent = revparse_single(update_ref); + size_t parents_count = 0; + if (parent) + { + parents_count = 1; + placeholder[0] = *parent; + } + return {placeholder, parents_count}; + } + }(); git_tree* tree; index_wrapper index = this->make_index(); @@ -133,11 +158,32 @@ void repository_wrapper::create_commit(const signature_wrapper::author_committer throw_if_error(git_tree_lookup(&tree, *this, &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.c_str(), tree, parent_count, parents)); + message_encoding, message.data(), tree, parents_count, parents)); git_tree_free(tree); } +std::optional repository_wrapper::resolve_local_ref +( + const std::string_view target_name +) const +{ + if (auto ref = this->find_reference_dwim(target_name)) + { + return this->find_annotated_commit(*ref); + } + else if (auto obj = this->revparse_single(target_name)) + { + return this->find_annotated_commit(obj->oid()); + } + else + { + return std::nullopt; + } +} + +// Annotated commits + annotated_commit_wrapper repository_wrapper::find_annotated_commit(const git_oid& id) const { git_annotated_commit* commit; @@ -145,6 +191,8 @@ annotated_commit_wrapper repository_wrapper::find_annotated_commit(const git_oid return annotated_commit_wrapper(commit); } +// Objects + std::optional repository_wrapper::revparse_single(std::string_view spec) const { git_object* obj; @@ -152,6 +200,15 @@ std::optional repository_wrapper::revparse_single(std::string_vi return rc == 0 ? std::make_optional(object_wrapper(obj)) : std::nullopt; } +object_wrapper repository_wrapper::find_object(const git_oid id, git_object_t type) +{ + git_object* object; + git_object_lookup(&object, *this, &id, type); + return object_wrapper(object); +} + +// Head manipulations + void repository_wrapper::set_head(std::string_view ref_name) { throw_if_error(git_repository_set_head(*this, ref_name.data())); @@ -168,3 +225,10 @@ void repository_wrapper::reset(const object_wrapper& target, git_reset_t reset_t throw_if_error(git_reset(*this, target, reset_type, &checkout_options)); } + +// Trees + +void repository_wrapper::checkout_tree(const object_wrapper& target, const git_checkout_options opts) +{ + throw_if_error(git_checkout_tree(*this, target, &opts)); +} diff --git a/src/wrapper/repository_wrapper.hpp b/src/wrapper/repository_wrapper.hpp index 7258724..78212cc 100644 --- a/src/wrapper/repository_wrapper.hpp +++ b/src/wrapper/repository_wrapper.hpp @@ -51,7 +51,8 @@ class repository_wrapper : public wrapper_base // Commits commit_wrapper find_commit(std::string_view ref_name = "HEAD") const; commit_wrapper find_commit(const git_oid& id) const; - void create_commit(const signature_wrapper::author_committer_signatures&, const std::string&); + void create_commit(const signature_wrapper::author_committer_signatures&, const std::string_view, const std::optional& parents_list); + std::optional resolve_local_ref(const std::string_view target_name) const; // Annotated commits annotated_commit_wrapper find_annotated_commit(const git_oid& id) const; @@ -61,12 +62,16 @@ class repository_wrapper : public wrapper_base // Objects std::optional revparse_single(std::string_view spec) const; + object_wrapper find_object(const git_oid id, git_object_t type); // Head manipulations void set_head(std::string_view ref_name); void set_head_detached(const annotated_commit_wrapper& commit); void reset(const object_wrapper& target, git_reset_t reset_type, const git_checkout_options& checkout_options); + // Trees + void checkout_tree(const object_wrapper& target, const git_checkout_options opts); + private: repository_wrapper() = default; diff --git a/src/wrapper/signature_wrapper.cpp b/src/wrapper/signature_wrapper.cpp index d16eaf9..b6148d9 100644 --- a/src/wrapper/signature_wrapper.cpp +++ b/src/wrapper/signature_wrapper.cpp @@ -1,6 +1,7 @@ #include "../wrapper/repository_wrapper.hpp" #include "../wrapper/signature_wrapper.hpp" #include "../utils/git_exception.hpp" +#include signature_wrapper::~signature_wrapper() { @@ -49,3 +50,23 @@ signature_wrapper signature_wrapper::get_commit_committer(const commit_wrapper& committer.m_ownership = false; return committer; } + +signature_wrapper signature_wrapper::signature_now(std::string_view name, std::string_view email) +{ + signature_wrapper sw; + git_signature* signature; + throw_if_error(git_signature_now(&signature, name.data(), email.data())); + sw.p_resource = signature; + sw.m_ownership = true; + return sw; +} + +signature_wrapper::author_committer_signatures signature_wrapper::signature_now( + std::string_view author_name, std::string_view author_email, std::string_view committer_name, std::string_view committer_email) +{ + signature_wrapper author_sig = signature_now(author_name.data(), author_email.data()); + signature_wrapper cmt_sig = signature_now(committer_name.data(), committer_email.data()); + // Deep copy of "when", which contains only copiable values, not pointers + cmt_sig.p_resource->when = author_sig.p_resource->when; + return std::pair(std::move(author_sig), std::move(cmt_sig)); +} diff --git a/src/wrapper/signature_wrapper.hpp b/src/wrapper/signature_wrapper.hpp index 2ebc861..68d9caa 100644 --- a/src/wrapper/signature_wrapper.hpp +++ b/src/wrapper/signature_wrapper.hpp @@ -13,12 +13,13 @@ class repository_wrapper; class signature_wrapper : public wrapper_base { public: + using author_committer_signatures = std::pair; ~signature_wrapper(); - signature_wrapper(signature_wrapper&&) = default; - signature_wrapper& operator=(signature_wrapper&&) = default; + signature_wrapper(signature_wrapper&&) noexcept = default; + signature_wrapper& operator=(signature_wrapper&&) noexcept = default; std::string_view name() const; std::string_view email() const; @@ -27,6 +28,14 @@ class signature_wrapper : public wrapper_base static author_committer_signatures get_default_signature_from_env(repository_wrapper&); static signature_wrapper get_commit_author(const commit_wrapper&); static signature_wrapper get_commit_committer(const commit_wrapper&); + static signature_wrapper signature_now(std::string_view name, std::string_view email); + static author_committer_signatures signature_now + ( + std::string_view author_name, + std::string_view author_email, + std::string_view committer_name, + std::string_view committer_email + ); private: diff --git a/src/wrapper/wrapper_base.hpp b/src/wrapper/wrapper_base.hpp index 08a5662..16e5dd2 100644 --- a/src/wrapper/wrapper_base.hpp +++ b/src/wrapper/wrapper_base.hpp @@ -1,6 +1,7 @@ #pragma once #include +#include template class wrapper_base @@ -38,3 +39,46 @@ class wrapper_base resource_type* p_resource = nullptr; }; + +template +class list_wrapper : public wrapper_base +{ +public: + + using base_type = wrapper_base; + + explicit list_wrapper(std::vector list) + : m_list(std::move(list)) + { + this->p_resource = new base_type::resource_type[m_list.size()]; + for (size_t i=0; i< m_list.size(); ++i) + { + this->p_resource[i] = m_list[i]; + } + } + + ~list_wrapper() + { + delete[] this->p_resource; + this->p_resource = nullptr; + } + + list_wrapper(list_wrapper&&) noexcept = default; + list_wrapper& operator=(list_wrapper&&) noexcept = default; + + size_t size() const + { + return m_list.size(); + } + + T front() + { + // TODO: rework wrapper so they can have references + // on libgit2 object without taking ownership + return T(std::move(m_list.front())); + } + +private: + + std::vector m_list; +}; diff --git a/test/conftest.py b/test/conftest.py index 576b5a3..aaf2ff5 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -4,7 +4,7 @@ import subprocess -# Fixture to run test in current tmp_path +# Fixture to run test in current tmp_path @pytest.fixture def run_in_tmp_path(tmp_path): original_cwd = os.getcwd() @@ -12,15 +12,18 @@ def run_in_tmp_path(tmp_path): yield os.chdir(original_cwd) -@pytest.fixture(scope='session') + +@pytest.fixture(scope="session") def git2cpp_path(): - 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): - url = 'https://github.com/xtensor-stack/xtl.git' - clone_cmd = [git2cpp_path, 'clone', url] - subprocess.run(clone_cmd, capture_output=True, cwd = tmp_path, text=True) + url = "https://github.com/xtensor-stack/xtl.git" + clone_cmd = [git2cpp_path, "clone", url] + subprocess.run(clone_cmd, capture_output=True, cwd=tmp_path, text=True) + @pytest.fixture def git_config(monkeypatch): diff --git a/test/test_merge.py b/test/test_merge.py new file mode 100644 index 0000000..95bcfc2 --- /dev/null +++ b/test/test_merge.py @@ -0,0 +1,46 @@ +import subprocess +import time + +import pytest + + +# 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, git_config, git2cpp_path, tmp_path, monkeypatch): + 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 + ) + assert p_checkout.returncode == 0 + + p = xtl_path / "mook_file.txt" + p.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) + 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) + assert p_commit.returncode == 0 + + checkout_cmd_2 = [git2cpp_path, "checkout", "master"] + p_checkout_2 = subprocess.run( + checkout_cmd_2, capture_output=True, cwd=xtl_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) + 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) + 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() From 437e2b778e393730adb550c0b89aedb145c768aa Mon Sep 17 00:00:00 2001 From: Sandrine Pataut Date: Fri, 31 Oct 2025 18:46:31 +0100 Subject: [PATCH 013/116] Add merge commit (#53) * Add merge commit * address review comments * small fix --- src/subcommand/merge_subcommand.cpp | 77 ++++++++++++++++++++++++++++- src/subcommand/merge_subcommand.hpp | 8 +++ src/wrapper/repository_wrapper.cpp | 6 +++ src/wrapper/repository_wrapper.hpp | 1 + src/wrapper/wrapper_base.hpp | 11 +++-- test/test_merge.py | 68 +++++++++++++++++++++++++ 6 files changed, 166 insertions(+), 5 deletions(-) diff --git a/src/subcommand/merge_subcommand.cpp b/src/subcommand/merge_subcommand.cpp index a9e62be..d537e1e 100644 --- a/src/subcommand/merge_subcommand.cpp +++ b/src/subcommand/merge_subcommand.cpp @@ -2,7 +2,7 @@ #include #include "merge_subcommand.hpp" -// #include "../wrapper/repository_wrapper.hpp" +#include merge_subcommand::merge_subcommand(const libgit2_object&, CLI::App& app) @@ -10,6 +10,9 @@ merge_subcommand::merge_subcommand(const libgit2_object&, CLI::App& app) auto *sub = app.add_subcommand("merge", "Join two or more development histories together"); sub->add_option("", m_branches_to_merge, "Branch(es) to merge"); + // sub->add_flag("--no-ff", m_no_ff, ""); + // sub->add_flag("--commit", m_commit, "Perform the merge and commit the result. This option can be used to override --no-commit."); + sub->add_flag("--no-commit", m_no_commit, "With --no-commit perform the merge and stop just before creating a merge commit, to give the user a chance to inspect and further tweak the merge result before committing. \nNote that fast-forward updates do not create a merge commit and therefore there is no way to stop those merges with --no-commit. Thus, if you want to ensure your branch is not changed or updated by the merge command, use --no-ff with --no-commit."); sub->callback([this]() { this->run(); }); } @@ -54,6 +57,42 @@ void perform_fastforward(repository_wrapper& repo, const git_oid target_oid, int target_ref.write_new_ref(target_oid); } +void merge_subcommand::create_merge_commit( + repository_wrapper& repo, + const index_wrapper& index, + const annotated_commit_list_wrapper& commits_to_merge, + size_t num_commits_to_merge) +{ + auto head_ref = repo.head(); + auto merge_ref = repo.find_reference_dwim(m_branches_to_merge.front()); + auto merge_commit = repo.resolve_local_ref(m_branches_to_merge.front()).value(); + + std::vector parents_list; + parents_list.reserve(num_commits_to_merge + 1); + parents_list.push_back(std::move(head_ref.peel())); + for (size_t i=0; ishort_name() : git_oid_tostr_s(&(merge_commit.oid())); + std::string msg = merge_ref ? "Merge branch " : "Merge commit "; + msg.append(msg_target); + + repo.create_commit(author_committer_sign_now, msg, std::optional(std::move(parents))); + + repo.state_cleanup(); +} + void merge_subcommand::run() { auto directory = get_current_git_path(); @@ -78,6 +117,7 @@ void merge_subcommand::run() if (analysis & GIT_MERGE_ANALYSIS_UP_TO_DATE) { std::cout << "Already up-to-date" << std::endl; + return; } else if (analysis & GIT_MERGE_ANALYSIS_UNBORN || (analysis & GIT_MERGE_ANALYSIS_FASTFORWARD && @@ -97,4 +137,39 @@ void merge_subcommand::run() assert(num_commits_to_merge == 1); perform_fastforward(repo, target_oid, (analysis & GIT_MERGE_ANALYSIS_UNBORN)); } + else if (analysis & GIT_MERGE_ANALYSIS_NORMAL) + { + git_merge_options merge_opts = GIT_MERGE_OPTIONS_INIT; + git_checkout_options checkout_opts = GIT_CHECKOUT_OPTIONS_INIT; + + merge_opts.flags = 0; + merge_opts.file_flags = GIT_MERGE_FILE_STYLE_DIFF3; + + checkout_opts.checkout_strategy = GIT_CHECKOUT_FORCE|GIT_CHECKOUT_ALLOW_CONFLICTS; + + if (preference & GIT_MERGE_PREFERENCE_FASTFORWARD_ONLY) + { + std::cout << "Fast-forward is preferred, but only a merge is possible\n" << std::endl; + } + + throw_if_error(git_merge(repo, + (const git_annotated_commit**)c_commits_to_merge, + num_commits_to_merge, + &merge_opts, + &checkout_opts)); + } + + index_wrapper index = repo.make_index(); + + if (git_index_has_conflicts(index)) + { + std::cout << "Conflict. To be implemented" << std::endl; + /* Handle conflicts */ + // output_conflicts(index); + } + else if (!m_no_commit) + { + create_merge_commit(repo, index, commits_to_merge, num_commits_to_merge); + printf("Merge made\n"); + } } diff --git a/src/subcommand/merge_subcommand.hpp b/src/subcommand/merge_subcommand.hpp index 3d73f47..c72855c 100644 --- a/src/subcommand/merge_subcommand.hpp +++ b/src/subcommand/merge_subcommand.hpp @@ -15,6 +15,14 @@ class merge_subcommand private: annotated_commit_list_wrapper resolve_heads(const repository_wrapper& repo); + void create_merge_commit( + repository_wrapper& repo, + const index_wrapper& index, + const annotated_commit_list_wrapper& commits_to_merge, + size_t num_commits_to_merge); std::vector m_branches_to_merge; + // bool m_no_ff = false; + // bool m_commit = false; + bool m_no_commit = false; }; diff --git a/src/wrapper/repository_wrapper.cpp b/src/wrapper/repository_wrapper.cpp index d099382..fcbd365 100644 --- a/src/wrapper/repository_wrapper.cpp +++ b/src/wrapper/repository_wrapper.cpp @@ -2,6 +2,7 @@ #include "../wrapper/index_wrapper.hpp" #include "../wrapper/object_wrapper.hpp" #include "../wrapper/commit_wrapper.hpp" +#include #include "../wrapper/repository_wrapper.hpp" repository_wrapper::~repository_wrapper() @@ -36,6 +37,11 @@ git_repository_state_t repository_wrapper::state() const return git_repository_state_t(git_repository_state(*this)); } +void repository_wrapper::state_cleanup() +{ + throw_if_error(git_repository_state_cleanup(*this)); +} + // References reference_wrapper repository_wrapper::head() const diff --git a/src/wrapper/repository_wrapper.hpp b/src/wrapper/repository_wrapper.hpp index 78212cc..99e36ae 100644 --- a/src/wrapper/repository_wrapper.hpp +++ b/src/wrapper/repository_wrapper.hpp @@ -30,6 +30,7 @@ class repository_wrapper : public wrapper_base static repository_wrapper clone(std::string_view url, std::string_view path, const git_clone_options& opts); git_repository_state_t state() const; + void state_cleanup(); // References reference_wrapper head() const; diff --git a/src/wrapper/wrapper_base.hpp b/src/wrapper/wrapper_base.hpp index 16e5dd2..def9b69 100644 --- a/src/wrapper/wrapper_base.hpp +++ b/src/wrapper/wrapper_base.hpp @@ -71,11 +71,14 @@ class list_wrapper : public wrapper_base return m_list.size(); } - T front() + const T& operator[](size_t pos) const { - // TODO: rework wrapper so they can have references - // on libgit2 object without taking ownership - return T(std::move(m_list.front())); + return m_list[pos]; + } + + const T& front() const + { + return m_list.front(); } private: diff --git a/test/test_merge.py b/test/test_merge.py index 95bcfc2..c123553 100644 --- a/test/test_merge.py +++ b/test/test_merge.py @@ -44,3 +44,71 @@ def test_merge_fast_forward(xtl_clone, git_config, git2cpp_path, tmp_path, monke assert "Author: Jane Doe" in p_log.stdout # assert "Commit: John Doe" in p_log.stdout assert (xtl_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 + ) + assert p_merge_2.returncode == 0 + assert p_merge_2.stdout == "Already up-to-date\n" + + +def test_merge(xtl_clone, git_config, git2cpp_path, tmp_path, monkeypatch): + 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 + ) + assert p_checkout.returncode == 0 + + p = xtl_path / "mook_file.txt" + p.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) + 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) + assert p_commit.returncode == 0 + + checkout_cmd_2 = [git2cpp_path, "checkout", "master"] + p_checkout_2 = subprocess.run( + checkout_cmd_2, capture_output=True, cwd=xtl_path, text=True + ) + assert p_checkout_2.returncode == 0 + + p = xtl_path / "mook_file_2.txt" + p.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) + 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 + ) + 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) + 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) + 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.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 + ) + assert p_merge_2.returncode == 0 + assert p_merge_2.stdout == "Already up-to-date\n" From 3cd3904ad310e56ebb3d31afb583a559a82fdf45 Mon Sep 17 00:00:00 2001 From: Sandrine Pataut Date: Mon, 3 Nov 2025 16:43:06 +0100 Subject: [PATCH 014/116] clean strings (#54) --- src/subcommand/checkout_subcommand.cpp | 6 +++--- src/subcommand/checkout_subcommand.hpp | 6 +++--- src/utils/git_exception.cpp | 2 +- src/utils/git_exception.hpp | 4 +++- 4 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/subcommand/checkout_subcommand.cpp b/src/subcommand/checkout_subcommand.cpp index d0103d7..8ae8ebe 100644 --- a/src/subcommand/checkout_subcommand.cpp +++ b/src/subcommand/checkout_subcommand.cpp @@ -59,7 +59,7 @@ void checkout_subcommand::run() annotated_commit_wrapper checkout_subcommand::create_local_branch ( repository_wrapper& repo, - const std::string& target_name, + const std::string_view target_name, bool force ) { @@ -71,7 +71,7 @@ void checkout_subcommand::checkout_tree ( const repository_wrapper& repo, const annotated_commit_wrapper& target_annotated_commit, - const std::string& target_name, + const std::string_view target_name, const git_checkout_options& options ) { @@ -83,7 +83,7 @@ void checkout_subcommand::update_head ( repository_wrapper& repo, const annotated_commit_wrapper& target_annotated_commit, - const std::string& target_name + const std::string_view target_name ) { std::string_view annotated_ref = target_annotated_commit.reference_name(); diff --git a/src/subcommand/checkout_subcommand.hpp b/src/subcommand/checkout_subcommand.hpp index 2aab79e..e041174 100644 --- a/src/subcommand/checkout_subcommand.hpp +++ b/src/subcommand/checkout_subcommand.hpp @@ -20,7 +20,7 @@ class checkout_subcommand annotated_commit_wrapper create_local_branch ( repository_wrapper& repo, - const std::string& target_name, + const std::string_view target_name, bool force ); @@ -28,7 +28,7 @@ class checkout_subcommand ( const repository_wrapper& repo, const annotated_commit_wrapper& target_annotated_commit, - const std::string& target_name, + const std::string_view target_name, const git_checkout_options& options ); @@ -36,7 +36,7 @@ class checkout_subcommand ( repository_wrapper& repo, const annotated_commit_wrapper& target_annotated_commit, - const std::string& target_name + const std::string_view target_name ); std::string m_branch_name = {}; diff --git a/src/utils/git_exception.cpp b/src/utils/git_exception.cpp index 7be528f..18bca2c 100644 --- a/src/utils/git_exception.cpp +++ b/src/utils/git_exception.cpp @@ -11,7 +11,7 @@ void throw_if_error(int exit_code) } -git_exception::git_exception(const std::string& message, int error_code) +git_exception::git_exception(const std::string_view message, int error_code) : m_message(message), m_error_code(error_code) {} diff --git a/src/utils/git_exception.hpp b/src/utils/git_exception.hpp index 673949d..e9d67ec 100644 --- a/src/utils/git_exception.hpp +++ b/src/utils/git_exception.hpp @@ -8,13 +8,15 @@ void throw_if_error(int exit_code); class git_exception : public std::exception { public: - git_exception(const std::string& message, int error_code); + + git_exception(const std::string_view message, int error_code); int error_code() const; const char* what() const noexcept override; private: + std::string m_message; int m_error_code; }; From 66670033d909579cf0dda2080151224ff58e1b10 Mon Sep 17 00:00:00 2001 From: Sandrine Pataut Date: Mon, 10 Nov 2025 18:02:10 +0100 Subject: [PATCH 015/116] Improve status output (#55) * improve status output * address review comments * address review comments --- src/subcommand/status_subcommand.cpp | 61 ++++++++++++++++++++-------- 1 file changed, 44 insertions(+), 17 deletions(-) diff --git a/src/subcommand/status_subcommand.cpp b/src/subcommand/status_subcommand.cpp index c24a3e2..1b9c1f1 100644 --- a/src/subcommand/status_subcommand.cpp +++ b/src/subcommand/status_subcommand.cpp @@ -26,16 +26,18 @@ status_subcommand::status_subcommand(const libgit2_object&, CLI::App& app) sub->callback([this]() { this->run(); }); }; -const std::string untracked_header = "Untracked files:\n"; -// "Untracked files:\n (use \"git add ...\" to include in what will be committed)"; +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"; -// "Changes to be committed:\n (use \"git reset HEAD ...\" to unstage)"; -const std::string ignored_header = "Ignored files:\n"; -// "Ignored files:\n (use \"git add -f ...\" to include in what will be committed)" -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)" -const std::string nothingtocommit_message = "No changes added to commit"; -// "No changes added to commit (use \"git add\" and/or \"git commit -a\")" +// (use \"git restore --staged ...\" to unstage)\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 (use \"git add ...\" to update what will be committed)\n"; +// (use \"git restore ...\" to discard changes in working directory)\n +// (use \"git checkout -- ...\" to discard changes in working directory)\n" +const std::string nothingtocommit_msg = "No changes added to commit (use \"git add\" and/or \"git commit -a\")"; +const std::string uptodate_msg = "Nothing to commit, working tree clean."; +const std::string nothingtocommit_untrackedfiles_msg = "Nothing added to commit but untracked files present (use \"git add\" to track)"; +// no changes added to commit (use "git add" and/or "git commit -a") struct status_messages { @@ -51,7 +53,7 @@ const std::map status_msg_map = //TODO : check { GIT_STATUS_INDEX_DELETED, {"D ", "\tdeleted:"} }, { GIT_STATUS_INDEX_RENAMED, {"R ", "\trenamed:"} }, { GIT_STATUS_INDEX_TYPECHANGE, {"T ", "\ttypechange:"} }, - { GIT_STATUS_WT_NEW, {"?? ", ""} }, + { GIT_STATUS_WT_NEW, {"?? ", " "} }, { GIT_STATUS_WT_MODIFIED, {" M " , "\tmodified:"} }, { GIT_STATUS_WT_DELETED, {" D ", "\tdeleted:"} }, { GIT_STATUS_WT_TYPECHANGE, {" T ", "\ttypechange:"} }, @@ -79,7 +81,14 @@ std::string get_print_status(git_status_t status, output_format of) std::string entry_status; if ((of == output_format::DEFAULT) || (of == output_format::LONG)) { - entry_status = status_msg_map.at(status).long_mod + " "; + if (status == GIT_STATUS_WT_NEW) + { + entry_status = status_msg_map.at(status).long_mod + "\t"; + } + else + { + entry_status = status_msg_map.at(status).long_mod + " "; + } } else if (of == output_format::SHORT) { @@ -88,14 +97,14 @@ std::string get_print_status(git_status_t status, output_format of) return entry_status; } -void update_tracked_dir_set(const char* old_path, const char* new_path, std::set* tracked_dir_set = nullptr) +void update_tracked_dir_set(const char* path, std::set* tracked_dir_set = nullptr) { if (tracked_dir_set) { - const size_t first_slash_idx = std::string_view(old_path).find('/'); + const size_t first_slash_idx = std::string_view(path).find('/'); if (std::string::npos != first_slash_idx) { - auto directory = std::string_view(old_path).substr(0, first_slash_idx); + auto directory = std::string_view(path).substr(0, first_slash_idx); tracked_dir_set->insert(std::string(directory)); } } @@ -131,7 +140,7 @@ std::vector get_entries_to_print(git_status_t status, status_list_w const char* old_path = diff_delta->old_file.path; const char* new_path = diff_delta->new_file.path; - update_tracked_dir_set(old_path, new_path, tracked_dir_set); + update_tracked_dir_set(old_path, tracked_dir_set); print_entry e = { get_print_status(status, of), get_print_item(old_path, new_path)}; @@ -164,7 +173,8 @@ void print_not_tracked(const std::vector& entries_to_print, const s const size_t first_slash_idx = e.item.find('/'); if (std::string::npos != first_slash_idx) { - auto directory = "\t" + e.item.substr(0, first_slash_idx) + "/"; + auto directory = e.item.substr(0, first_slash_idx); + auto directory_print = e.item.substr(0, first_slash_idx) + "/"; if (tracked_dir_set.contains(directory)) { not_tracked_entries_to_print.push_back(e); @@ -175,7 +185,7 @@ void print_not_tracked(const std::vector& entries_to_print, const s {} else { - not_tracked_entries_to_print.push_back({e.status, directory}); + not_tracked_entries_to_print.push_back({e.status, directory_print}); untracked_dir_set.insert(std::string(directory)); } } @@ -290,4 +300,21 @@ void status_subcommand::run() // std::cout << std::endl; // } // } + + if (!sl.has_tobecommited_header() && (sl.has_notstagged_header() || sl.has_untracked_header())) + { + if (sl.has_untracked_header()) + { + std::cout << nothingtocommit_untrackedfiles_msg << std::endl; + } + else + { + std::cout << nothingtocommit_msg << std::endl; + } + } + + if (!sl.has_notstagged_header() && !sl.has_untracked_header()) + { + std::cout << uptodate_msg << std::endl; + } } From 9e6938b088a34a7e51a2c9d8364bec205fc6c0a9 Mon Sep 17 00:00:00 2001 From: Ian Thomas Date: Thu, 27 Nov 2025 10:36:49 +0000 Subject: [PATCH 016/116] Initialise m_bare flag in init subcommand (#56) --- src/subcommand/init_subcommand.hpp | 2 +- test/test_init.py | 24 +++++++++++++++++++++++- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/src/subcommand/init_subcommand.hpp b/src/subcommand/init_subcommand.hpp index 6086492..9fb9ffc 100644 --- a/src/subcommand/init_subcommand.hpp +++ b/src/subcommand/init_subcommand.hpp @@ -14,6 +14,6 @@ class init_subcommand void run(); private: - bool m_bare; + bool m_bare = false; std::string m_directory; }; diff --git a/test/test_init.py b/test/test_init.py index fa23ebf..aec88a0 100644 --- a/test/test_init.py +++ b/test/test_init.py @@ -37,7 +37,29 @@ def test_init_in_cwd(git2cpp_path, tmp_path, run_in_tmp_path): # TODO: check this is a valid git repo -# TODO: Test without bare flag. +def test_init_not_bare(git2cpp_path, tmp_path): + # tmp_path exists and is empty. + assert list(tmp_path.iterdir()) == [] + + cmd = [git2cpp_path, 'init', '.'] + p = subprocess.run(cmd, capture_output=True, cwd=tmp_path) + assert p.returncode == 0 + assert p.stdout == b'' + assert p.stderr == b'' + + # Directory contains just .git directory. + assert sorted(map(lambda path: path.name, tmp_path.iterdir())) == ['.git'] + # .git directory is a valid repo. + assert sorted(map(lambda path: path.name, (tmp_path / '.git').iterdir())) == [ + 'HEAD', 'config', 'description', 'hooks', 'info', 'objects', 'refs' + ] + + # Would like to use `git2cpp status` but it complains that 'refs/heads/master' not found + cmd = [git2cpp_path, 'log'] + p = subprocess.run(cmd, capture_output=True, cwd=tmp_path) + assert p.returncode == 0 + assert p.stdout == b'' + assert p.stderr == b'' def test_error_on_unknown_option(git2cpp_path): From 72a2fc2db95c4509d8d1a726148f5f91c32d925f Mon Sep 17 00:00:00 2001 From: Sandrine Pataut Date: Wed, 3 Dec 2025 14:52:40 +0100 Subject: [PATCH 017/116] Merge conflict (#60) Merge conflicts --- src/subcommand/merge_subcommand.cpp | 194 ++++++++++++++++++++++++--- src/subcommand/merge_subcommand.hpp | 4 + src/subcommand/status_subcommand.cpp | 103 ++++++-------- src/utils/common.cpp | 28 ++++ src/utils/common.hpp | 8 ++ src/wrapper/index_wrapper.cpp | 54 +++++++- src/wrapper/index_wrapper.hpp | 6 + src/wrapper/status_wrapper.cpp | 8 ++ src/wrapper/status_wrapper.hpp | 2 + test/test_merge.py | 136 +++++++++++++++++-- 10 files changed, 452 insertions(+), 91 deletions(-) diff --git a/src/subcommand/merge_subcommand.cpp b/src/subcommand/merge_subcommand.cpp index d537e1e..53d2115 100644 --- a/src/subcommand/merge_subcommand.cpp +++ b/src/subcommand/merge_subcommand.cpp @@ -1,8 +1,11 @@ #include +#include #include +#include +#include #include "merge_subcommand.hpp" -#include +#include "../wrapper/status_wrapper.hpp" merge_subcommand::merge_subcommand(const libgit2_object&, CLI::App& app) @@ -10,9 +13,12 @@ merge_subcommand::merge_subcommand(const libgit2_object&, CLI::App& app) auto *sub = app.add_subcommand("merge", "Join two or more development histories together"); sub->add_option("", m_branches_to_merge, "Branch(es) to merge"); - // sub->add_flag("--no-ff", m_no_ff, ""); + // sub->add_flag("--no-ff", m_no_ff, "Create a merge commit in all cases, even when the merge could instead be resolved as a fast-forward."); // sub->add_flag("--commit", m_commit, "Perform the merge and commit the result. This option can be used to override --no-commit."); sub->add_flag("--no-commit", m_no_commit, "With --no-commit perform the merge and stop just before creating a merge commit, to give the user a chance to inspect and further tweak the merge result before committing. \nNote that fast-forward updates do not create a merge commit and therefore there is no way to stop those merges with --no-commit. Thus, if you want to ensure your branch is not changed or updated by the merge command, use --no-ff with --no-commit."); + sub->add_flag("--abort", m_abort, "Abort the current conflict resolution process, and try to reconstruct the pre-merge state. If an autostash entry is present, apply it to the worktree.\nIf there were uncommitted worktree changes present when the merge started, git merge --abort will in some cases be unable to reconstruct these changes. It is therefore recommended to always commit or stash your changes before running git merge.\ngit merge --abort is equivalent to git reset --merge when MERGE_HEAD is present unless MERGE_AUTOSTASH is also present in which case git merge --abort applies the stash entry to the worktree whereas git reset --merge will save the stashed changes in the stash list."); + sub->add_flag("--quit", m_quit, "Forget about the current merge in progress. Leave the index and the working tree as-is. If MERGE_AUTOSTASH is present, the stash entry will be saved to the stash list."); + sub->add_flag("--continue", m_continue, "After a git merge stops due to conflicts you can conclude the merge by running git merge --continue"); // (see "HOW TO RESOLVE CONFLICTS" section below). sub->callback([this]() { this->run(); }); } @@ -33,7 +39,23 @@ annotated_commit_list_wrapper merge_subcommand::resolve_heads(const repository_w return annotated_commit_list_wrapper(std::move(commits_to_merge)); } -void perform_fastforward(repository_wrapper& repo, const git_oid target_oid, int is_unborn) +annotated_commit_list_wrapper resolve_mergeheads(const repository_wrapper& repo, const std::vector& oid_list) +{ + std::vector commits_to_merge; + commits_to_merge.reserve(oid_list.size()); + + for (const auto& id:oid_list) + { + std::optional commit = repo.find_annotated_commit(id); + if (commit.has_value()) + { + commits_to_merge.push_back(std::move(commit).value()); + } + } + return annotated_commit_list_wrapper(std::move(commits_to_merge)); +} + +void perform_fastforward(repository_wrapper& repo, const git_oid& target_oid, int is_unborn) { const git_checkout_options ff_checkout_options = GIT_CHECKOUT_OPTIONS_INIT; @@ -60,12 +82,13 @@ void perform_fastforward(repository_wrapper& repo, const git_oid target_oid, int void merge_subcommand::create_merge_commit( repository_wrapper& repo, const index_wrapper& index, + const std::vector& branches_to_merge, const annotated_commit_list_wrapper& commits_to_merge, size_t num_commits_to_merge) { auto head_ref = repo.head(); - auto merge_ref = repo.find_reference_dwim(m_branches_to_merge.front()); - auto merge_commit = repo.resolve_local_ref(m_branches_to_merge.front()).value(); + auto merge_ref = repo.find_reference_dwim(branches_to_merge.front()); + auto merge_commit = repo.resolve_local_ref(branches_to_merge.front()).value(); std::vector parents_list; parents_list.reserve(num_commits_to_merge + 1); @@ -85,6 +108,7 @@ void merge_subcommand::create_merge_commit( // TODO: add a prompt to edit the merge message std::string msg_target = merge_ref ? merge_ref->short_name() : git_oid_tostr_s(&(merge_commit.oid())); + msg_target = "\'" + msg_target + "\'"; std::string msg = merge_ref ? "Merge branch " : "Merge commit "; msg.append(msg_target); @@ -93,15 +117,159 @@ void merge_subcommand::create_merge_commit( repo.state_cleanup(); } +// This function is used as a callback in git_repository_mergehead_foreach and therefore its type must be git_repository_mergehead_foreach_cb. +int populate_list(const git_oid* oid, void* payload) +{ + auto* l = reinterpret_cast*>(payload); + l->push_back(*oid); + return 0; +} + void merge_subcommand::run() { auto directory = get_current_git_path(); auto bare = false; auto repo = repository_wrapper::open(directory); - auto state = repo.state(); - if (state != GIT_REPOSITORY_STATE_NONE) + index_wrapper index = repo.make_index(); + stream_colour_fn yellow = termcolor::yellow; + + if (state == GIT_REPOSITORY_STATE_MERGE) { + if (m_abort) + { + // git merge --abort is equivalent to git reset --merge when MERGE_HEAD is present + // unless MERGE_AUTOSTASH is also present in which case git merge --abort applies + // the stash entry to the worktree whereas git reset --merge will save the stashed + // changes in the stash list. + + if (m_quit | m_continue) + { + std::cout << "fatal: --abort expects no arguments" << std::endl; // TODO: add the help info + return; + } + + std::cout << "Warning: 'merge --abort' is not implemented yet. A 'reset --hard HEAD' will be executed." << std::endl; + std::cout << "Do you want to continue [y/N] ?" << std::endl; + std::string answer; + std::cin >> answer; + if (answer == "y") + { + repo.state_cleanup(); + index.conflict_cleanup(); + + git_checkout_options options; + git_checkout_options_init(&options, GIT_CHECKOUT_OPTIONS_VERSION); + auto head_ref = repo.head(); + repo.reset(head_ref.peel(), GIT_RESET_HARD, options); + } + else + { + std::cout << "Abort." << std::endl; // maybe another message would be more clear? + } + return; + } + else if (m_quit) + { + // Forget about the current merge in progress. Leave the index and the working tree as-is. + // If MERGE_AUTOSTASH is present, the stash entry will be saved to the stash list. + // + + // if (m_continue) + // { + // std::cout << "fatal: --abort expects no arguments" << std::endl; // TODO: add the help info + // return; + // } + + // problem: can't do a reset if the state is not cleaned up, but it shouldn't be. + // Idem for the index and the conflicts. + + // repo.state_cleanup(); + // index.conflict_cleanup(); + + // git_checkout_options options; + // git_checkout_options_init(&options, GIT_CHECKOUT_OPTIONS_VERSION); + // auto head_ref = repo.head(); + // repo.reset(head_ref.peel(), GIT_RESET_SOFT, options); + + std::cout << "merge --quit is not implemented yet." << std::endl; + return; + } + else if (m_continue) + { + auto sl = status_list_wrapper::status_list(repo); + if (!sl.has_unmerged_header()) + { + // std::string commit_message = "Merge branch "; // how to get the name of the branch the merge was started on ? + // auto author_committer_signatures = signature_wrapper::get_default_signature_from_env(repo); + // repo.create_commit(author_committer_signatures, commit_message, std::nullopt); + + std::vector oid_list; + git_repository_mergehead_foreach(repo, populate_list, &oid_list); + + annotated_commit_list_wrapper commits_to_merge = resolve_mergeheads(repo, oid_list); + size_t num_commits_to_merge = commits_to_merge.size(); + + std::vector branches_to_merge_names; + for (const auto& id:oid_list) + { + git_reference_iterator* iter; + git_reference_iterator_new(&iter, repo); + git_reference* ref; + git_reference_next(&ref, iter); + if (git_oid_equal(git_reference_target(ref), &id)) + { + auto name = git_reference_name(ref); + branches_to_merge_names.push_back(name); + } + git_reference_free(ref); + } + + create_merge_commit(repo, index, branches_to_merge_names, commits_to_merge, num_commits_to_merge); + std::cout << "Merge made" << std::endl; // TODO: change the outpout to something like this: 3c22161 (HEAD -> master) Merge branch 'foregone' + + repo.state_cleanup(); + index.conflict_cleanup(); + return; + } + else + { + auto entry_status = get_status_msg(GIT_STATUS_CONFLICTED).short_mod; + const auto& entry_list = sl.get_entry_list(GIT_STATUS_CONFLICTED); + for (auto* entry : entry_list) + { + git_diff_delta* diff_delta = entry->head_to_index; //ou entry->index_to_workdir ??? + const char* old_path = diff_delta->old_file.path; + std::cout << entry_status << "\t" << old_path << std::endl; + } + std::cout << "error: Committing is not possible because you have unmerged files." << std::endl; + } + } + else + { + std::cout << "error: Merging is not possible because you have unmerged files." << std::endl; + } + std::cout << yellow << "hint: Fix them up in the work tree, and then use 'git add/rm '" << std::endl; + std::cout << "hint: as appropriate to mark resolution and make a commit." << termcolor::reset << std::endl; + std::cout << "fatal: Exiting because of an unresolved conflict." << std::endl; + return; + } + else + { + if (m_abort) + { + std::cout << "fatal: There is no merge to abort (MERGE_HEAD missing)." << std::endl; + return; + } + if (m_continue) + { + std::cout << "fatal: There is no merge in progress (MERGE_HEAD missing)." << std::endl; + return; + } + } + + if (state != GIT_REPOSITORY_STATE_NONE) // Could this be a "else if before the "else" above ? + { std::cout << "repository is in unexpected state " << state <& branches_to_merge, const annotated_commit_list_wrapper& commits_to_merge, size_t num_commits_to_merge); @@ -25,4 +26,7 @@ class merge_subcommand // bool m_no_ff = false; // bool m_commit = false; bool m_no_commit = false; + bool m_abort = false; + bool m_quit = false; + bool m_continue = false; }; diff --git a/src/subcommand/status_subcommand.cpp b/src/subcommand/status_subcommand.cpp index 1b9c1f1..36aa213 100644 --- a/src/subcommand/status_subcommand.cpp +++ b/src/subcommand/status_subcommand.cpp @@ -27,41 +27,13 @@ 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 restore --staged ...\" to unstage)\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 (use \"git add ...\" to update what will be committed)\n"; -// (use \"git restore ...\" to discard changes in working directory)\n -// (use \"git checkout -- ...\" to discard changes in working directory)\n" -const std::string nothingtocommit_msg = "No changes added to commit (use \"git add\" and/or \"git commit -a\")"; -const std::string uptodate_msg = "Nothing to commit, working tree clean."; -const std::string nothingtocommit_untrackedfiles_msg = "Nothing added to commit but untracked files present (use \"git add\" to track)"; -// no changes added to commit (use "git add" and/or "git commit -a") - -struct status_messages -{ - std::string short_mod; - std::string long_mod; -}; - -const std::map status_msg_map = //TODO : check spaces in short_mod -{ - { GIT_STATUS_CURRENT, {"", ""} }, - { GIT_STATUS_INDEX_NEW, {"A ", "\tnew file:"} }, - { GIT_STATUS_INDEX_MODIFIED, {"M ", "\tmodified:"} }, - { GIT_STATUS_INDEX_DELETED, {"D ", "\tdeleted:"} }, - { GIT_STATUS_INDEX_RENAMED, {"R ", "\trenamed:"} }, - { GIT_STATUS_INDEX_TYPECHANGE, {"T ", "\ttypechange:"} }, - { GIT_STATUS_WT_NEW, {"?? ", " "} }, - { GIT_STATUS_WT_MODIFIED, {" M " , "\tmodified:"} }, - { GIT_STATUS_WT_DELETED, {" D ", "\tdeleted:"} }, - { GIT_STATUS_WT_TYPECHANGE, {" T ", "\ttypechange:"} }, - { GIT_STATUS_WT_RENAMED, {" R ", "\trenamed:"} }, - { GIT_STATUS_WT_UNREADABLE, {"", ""} }, - { GIT_STATUS_IGNORED, {"!! ", ""} }, - { GIT_STATUS_CONFLICTED, {"", ""} }, -}; +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 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 treeclean_message = "Nothing to commit, working tree clean"; enum class output_format { @@ -81,18 +53,11 @@ std::string get_print_status(git_status_t status, output_format of) std::string entry_status; if ((of == output_format::DEFAULT) || (of == output_format::LONG)) { - if (status == GIT_STATUS_WT_NEW) - { - entry_status = status_msg_map.at(status).long_mod + "\t"; - } - else - { - entry_status = status_msg_map.at(status).long_mod + " "; - } + entry_status = get_status_msg(status).long_mod; } else if (of == output_format::SHORT) { - entry_status = status_msg_map.at(status).short_mod; + entry_status = get_status_msg(status).short_mod; } return entry_status; } @@ -173,8 +138,7 @@ void print_not_tracked(const std::vector& entries_to_print, const s const size_t first_slash_idx = e.item.find('/'); if (std::string::npos != first_slash_idx) { - auto directory = e.item.substr(0, first_slash_idx); - auto directory_print = e.item.substr(0, first_slash_idx) + "/"; + auto directory = e.item.substr(0, first_slash_idx) + "/"; if (tracked_dir_set.contains(directory)) { not_tracked_entries_to_print.push_back(e); @@ -185,7 +149,7 @@ void print_not_tracked(const std::vector& entries_to_print, const s {} else { - not_tracked_entries_to_print.push_back({e.status, directory_print}); + not_tracked_entries_to_print.push_back({e.status, directory}); untracked_dir_set.insert(std::string(directory)); } } @@ -228,7 +192,12 @@ void status_subcommand::run() is_long = ((of == output_format::DEFAULT) || (of == output_format::LONG)); if (is_long) { - std::cout << "On branch " << branch_name << std::endl; + std::cout << "On branch " << branch_name << "\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 { @@ -273,6 +242,21 @@ void status_subcommand::run() } } + // 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; + } + } + if (sl.has_untracked_header()) { stream_colour_fn colour = termcolor::red; @@ -287,6 +271,12 @@ void status_subcommand::run() } } + // 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())) + { + std::cout << treeclean_message << std::endl; + } + // if (sl.has_ignored_header()) // { // stream_colour_fn colour = termcolor::red; @@ -300,21 +290,4 @@ void status_subcommand::run() // std::cout << std::endl; // } // } - - if (!sl.has_tobecommited_header() && (sl.has_notstagged_header() || sl.has_untracked_header())) - { - if (sl.has_untracked_header()) - { - std::cout << nothingtocommit_untrackedfiles_msg << std::endl; - } - else - { - std::cout << nothingtocommit_msg << std::endl; - } - } - - if (!sl.has_notstagged_header() && !sl.has_untracked_header()) - { - std::cout << uptodate_msg << std::endl; - } } diff --git a/src/utils/common.cpp b/src/utils/common.cpp index 9a5787c..a9b84d4 100644 --- a/src/utils/common.cpp +++ b/src/utils/common.cpp @@ -1,6 +1,7 @@ #include #include #include +#include #include @@ -26,6 +27,33 @@ std::string get_current_git_path() // ->check(CLI::ExistingDirectory | CLI::NonexistentPath) // ->default_val(std::filesystem::current_path()); +const std::map& get_status_msg_map() +{ + static std::map status_msg_map = //TODO : check spaces in short_mod + { + { GIT_STATUS_CURRENT, {"", ""} }, + { GIT_STATUS_INDEX_NEW, {"A ", "\tnew file: "} }, + { GIT_STATUS_INDEX_MODIFIED, {"M ", "\tmodified: "} }, + { GIT_STATUS_INDEX_DELETED, {"D ", "\tdeleted: "} }, + { GIT_STATUS_INDEX_RENAMED, {"R ", "\trenamed: "} }, + { GIT_STATUS_INDEX_TYPECHANGE, {"T ", "\ttypechange: "} }, + { GIT_STATUS_WT_NEW, {"?? ", "\t"} }, + { GIT_STATUS_WT_MODIFIED, {" M " , "\tmodified: "} }, + { GIT_STATUS_WT_DELETED, {" D ", "\tdeleted: "} }, + { GIT_STATUS_WT_TYPECHANGE, {" T ", "\ttypechange: "} }, + { GIT_STATUS_WT_RENAMED, {" R ", "\trenamed: "} }, + { GIT_STATUS_WT_UNREADABLE, {"", ""} }, + { GIT_STATUS_IGNORED, {"!! ", ""} }, + { GIT_STATUS_CONFLICTED, {"AA ", "\tboth added: "} }, + }; + return status_msg_map; +} + +status_messages get_status_msg(git_status_t st) +{ + return get_status_msg_map().find(st)->second; +} + git_strarray_wrapper::git_strarray_wrapper(std::vector patterns) : m_patterns(std::move(patterns)) { diff --git a/src/utils/common.hpp b/src/utils/common.hpp index e3b959c..6751b46 100644 --- a/src/utils/common.hpp +++ b/src/utils/common.hpp @@ -28,6 +28,14 @@ class libgit2_object : private noncopyable_nonmovable std::string get_current_git_path(); +struct status_messages +{ + std::string short_mod; + std::string long_mod; +}; + +status_messages get_status_msg(git_status_t); + using stream_colour_fn = std::ostream& (*)(std::ostream&); class git_strarray_wrapper diff --git a/src/wrapper/index_wrapper.cpp b/src/wrapper/index_wrapper.cpp index 03e8afb..7ff0ce2 100644 --- a/src/wrapper/index_wrapper.cpp +++ b/src/wrapper/index_wrapper.cpp @@ -1,9 +1,12 @@ +#include +#include +#include + #include "index_wrapper.hpp" #include "../utils/common.hpp" #include "../utils/git_exception.hpp" #include "../wrapper/repository_wrapper.hpp" -#include index_wrapper::~index_wrapper() { @@ -32,7 +35,6 @@ void index_wrapper::add_impl(std::vector patterns) { git_strarray_wrapper array{patterns}; throw_if_error(git_index_add_all(*this, array, 0, NULL, NULL)); - // throw_if_error(git_index_write(*this)); } void index_wrapper::write() @@ -46,3 +48,51 @@ git_oid index_wrapper::write_tree() throw_if_error(git_index_write_tree(&tree_id, *this)); return tree_id; } + +bool index_wrapper::has_conflict() const +{ + return git_index_has_conflicts(*this); +} + +git_index_conflict_iterator* index_wrapper::create_conflict_iterator() +{ + git_index_conflict_iterator* conflict_iterator; + throw_if_error(git_index_conflict_iterator_new(&conflict_iterator, *this)); + return conflict_iterator; +} + +void index_wrapper::output_conflicts() +{ + git_index_conflict_iterator* conflicts = create_conflict_iterator(); + + const git_index_entry* ancestor; + const git_index_entry* our; + const git_index_entry* their; + int err = 0; + std::string msg_conflict; + + while ((err = git_index_conflict_next(&ancestor, &our, &their, conflicts)) == 0) + { + std::string ancestor_path = ancestor ? ancestor->path : ""; + std::string our_path = our->path ? our->path : "NULL"; + std::string their_path = their->path ? their->path : "NULL"; + msg_conflict = "conflict: " + ancestor_path + " " + our_path + " " + their_path; + std::cout << msg_conflict << std::endl; +// Message with git is a bit different: +// Auto-merging mook_file.txt +// CONFLICT (add/add): Merge conflict in mook_file.txt +// Automatic merge failed; fix conflicts and then commit the result. + } + + if (err != GIT_ITEROVER) + { + std::cout << "error iterating conflicts" << std::endl; + } + + git_index_conflict_iterator_free(conflicts); +} + +void index_wrapper::conflict_cleanup() +{ + throw_if_error(git_index_conflict_cleanup(*this)); +} diff --git a/src/wrapper/index_wrapper.hpp b/src/wrapper/index_wrapper.hpp index 2095fe0..0fa8b55 100644 --- a/src/wrapper/index_wrapper.hpp +++ b/src/wrapper/index_wrapper.hpp @@ -26,9 +26,15 @@ class index_wrapper : public wrapper_base void add_entries(std::vector patterns); void add_all(); + bool has_conflict() const; + void output_conflicts(); + void conflict_cleanup(); + private: index_wrapper() = default; void add_impl(std::vector patterns); + + git_index_conflict_iterator* create_conflict_iterator(); }; diff --git a/src/wrapper/status_wrapper.cpp b/src/wrapper/status_wrapper.cpp index a1962d8..a6fe876 100644 --- a/src/wrapper/status_wrapper.cpp +++ b/src/wrapper/status_wrapper.cpp @@ -35,6 +35,10 @@ status_list_wrapper status_list_wrapper::status_list(const repository_wrapper& r { res.m_ignored_header_flag = true; } + if (!res.get_entry_list(GIT_STATUS_CONFLICTED).empty()) + { + res.m_unmerged_header_flag = true; + } // if (!res.tobecommited_header_flag) // { // res.m_nothingtocommit_message_flag = true; @@ -59,6 +63,10 @@ bool status_list_wrapper::has_notstagged_header() const { return m_notstagged_header_flag; } +bool status_list_wrapper::has_unmerged_header() const +{ + return m_unmerged_header_flag; +} bool status_list_wrapper::has_nothingtocommit_message() const { return m_nothingtocommit_message_flag; diff --git a/src/wrapper/status_wrapper.hpp b/src/wrapper/status_wrapper.hpp index 2a8335d..b20e18a 100644 --- a/src/wrapper/status_wrapper.hpp +++ b/src/wrapper/status_wrapper.hpp @@ -26,6 +26,7 @@ class status_list_wrapper : public wrapper_base bool has_tobecommited_header() const; bool has_ignored_header() const; bool has_notstagged_header() const; + bool has_unmerged_header() const; bool has_nothingtocommit_message() const; private: @@ -39,5 +40,6 @@ class status_list_wrapper : public wrapper_base bool m_tobecommited_header_flag = false; bool m_ignored_header_flag = false; bool m_notstagged_header_flag = false; + bool m_unmerged_header_flag = false; bool m_nothingtocommit_message_flag = false; }; diff --git a/test/test_merge.py b/test/test_merge.py index c123553..0f531f0 100644 --- a/test/test_merge.py +++ b/test/test_merge.py @@ -1,5 +1,4 @@ import subprocess -import time import pytest @@ -16,8 +15,8 @@ def test_merge_fast_forward(xtl_clone, git_config, git2cpp_path, tmp_path, monke ) assert p_checkout.returncode == 0 - p = xtl_path / "mook_file.txt" - p.write_text("blablabla") + file_path = xtl_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) @@ -53,7 +52,7 @@ def test_merge_fast_forward(xtl_clone, git_config, git2cpp_path, tmp_path, monke assert p_merge_2.stdout == "Already up-to-date\n" -def test_merge(xtl_clone, git_config, git2cpp_path, tmp_path, monkeypatch): +def test_merge_commit(xtl_clone, git_config, git2cpp_path, tmp_path, monkeypatch): assert (tmp_path / "xtl").exists() xtl_path = tmp_path / "xtl" @@ -63,8 +62,8 @@ def test_merge(xtl_clone, git_config, git2cpp_path, tmp_path, monkeypatch): ) assert p_checkout.returncode == 0 - p = xtl_path / "mook_file.txt" - p.write_text("blablabla") + file_path = xtl_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) @@ -80,8 +79,8 @@ def test_merge(xtl_clone, git_config, git2cpp_path, tmp_path, monkeypatch): ) assert p_checkout_2.returncode == 0 - p = xtl_path / "mook_file_2.txt" - p.write_text("BLABLABLA") + file_path_2 = xtl_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) @@ -104,7 +103,7 @@ def test_merge(xtl_clone, git_config, git2cpp_path, tmp_path, monkeypatch): # 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.txt").exists() + assert (xtl_path / "mook_file_2.txt").exists() merge_cmd_2 = [git2cpp_path, "merge", "foregone"] p_merge_2 = subprocess.run( @@ -112,3 +111,122 @@ def test_merge(xtl_clone, git_config, git2cpp_path, tmp_path, monkeypatch): ) 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, git_config, git2cpp_path, tmp_path, monkeypatch, flag +): + 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 + ) + assert p_checkout.returncode == 0 + + file_path = xtl_path / "mook_file.txt" + file_path.write_text("blablabla") + + file_path_2 = xtl_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) + 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) + assert p_commit.returncode == 0 + + checkout_cmd_2 = [git2cpp_path, "checkout", "master"] + p_checkout_2 = subprocess.run( + checkout_cmd_2, capture_output=True, cwd=xtl_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) + 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 + ) + 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) + assert p_merge.returncode == 0 + assert "conflict: " in p_merge.stdout + + flag_cmd = [git2cpp_path, "merge", flag] + if flag == "--abort": + for answer in {"y", ""}: + p_abort = subprocess.run( + flag_cmd, input=answer, capture_output=True, cwd=xtl_path, text=True + ) + 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 + + elif flag == "--quit": + pass + # p_quit = subprocess.run(flag_cmd, capture_output=True, cwd=xtl_path, text=True) + # assert p_quit.returncode == 0 + # assert (xtl_path / "mook_file.txt").exists() + # with open(xtl_path / "mook_file.txt") as f: + # lines = f.readlines() + # assert "<<<<<<< HEAD" in lines[0] + # assert ">>>>>>> foregone" in lines[-1] + + # p_merge_2 = subprocess.run( + # merge_cmd, capture_output=True, cwd=xtl_path, text=True + # ) + # assert p_merge_2.returncode != 0 + # print(p_merge_2.stdout) + # assert "error: Merging is not possible because you have unmerged files." in p_merge_2.stdout + + elif flag == "--continue": + # Create another branch pointing to the same commit (alias branch). + # 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 + ) + 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) + assert p_add.returncode == 0 + + p_continue = subprocess.run( + flag_cmd, capture_output=True, cwd=xtl_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) + 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() + + merge_cmd_2 = [git2cpp_path, "merge", "foregone"] + p_merge_2 = subprocess.run( + merge_cmd_2, capture_output=True, cwd=xtl_path, text=True + ) + assert p_merge_2.returncode == 0 + assert p_merge_2.stdout == "Already up-to-date\n" From 6342fb263ccaec188be0c5b10f7622533ca97fea Mon Sep 17 00:00:00 2001 From: Sandrine Pataut Date: Wed, 10 Dec 2025 11:22:48 +0100 Subject: [PATCH 018/116] change release version to 0.0.5 (#61) --- src/version.hpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/version.hpp b/src/version.hpp index cb8064c..31704ac 100644 --- a/src/version.hpp +++ b/src/version.hpp @@ -2,7 +2,7 @@ #define GIT2CPP_VERSION_MAJOR 0 #define GIT2CPP_VERSION_MINOR 0 -#define GIT2CPP_VERSION_PATCH 4 +#define GIT2CPP_VERSION_PATCH 5 // e.g. ".rc0" #define GIT2CPP_VERSION_SUFFIX From 324e7c27cc46552c65cf2b197119e93f6b30e48a Mon Sep 17 00:00:00 2001 From: Julien Jerphanion Date: Thu, 11 Dec 2025 22:43:37 +0100 Subject: [PATCH 019/116] feat: Add remote management, fetch, and push subcommands (#59) * feat: Add remote management, fetch, and push subcommands - Implement remote add/remove/rename/set-url/show operations - Add fetch and push subcommands for remote synchronization - Create remote_wrapper class for RAII management - Add comprehensive test suite (19 tests, all passing) - Fix CMakeLists.txt to find CLI11 in conda/pixi environment Signed-off-by: Julien Jerphanion * Address review comments Signed-off-by: Julien Jerphanion Co-authored-by: Johan Mabille * edit remote * small fix in test * another small fix --------- Signed-off-by: Julien Jerphanion Co-authored-by: Johan Mabille Co-authored-by: Sandrine Pataut --- CMakeLists.txt | 10 + src/main.cpp | 6 + src/subcommand/clone_subcommand.cpp | 80 +----- src/subcommand/clone_subcommand.hpp | 1 + src/subcommand/fetch_subcommand.cpp | 54 ++++ src/subcommand/fetch_subcommand.hpp | 19 ++ src/subcommand/push_subcommand.cpp | 54 ++++ src/subcommand/push_subcommand.hpp | 21 ++ src/subcommand/remote_subcommand.cpp | 194 ++++++++++++++ src/subcommand/remote_subcommand.hpp | 34 +++ src/utils/progress.cpp | 155 +++++++++++ src/utils/progress.hpp | 10 + src/wrapper/remote_wrapper.cpp | 63 +++++ src/wrapper/remote_wrapper.hpp | 36 +++ src/wrapper/repository_wrapper.cpp | 68 +++++ src/wrapper/repository_wrapper.hpp | 9 + test/test_clone.py | 22 +- test/test_remote.py | 383 +++++++++++++++++++++++++++ 18 files changed, 1140 insertions(+), 79 deletions(-) create mode 100644 src/subcommand/fetch_subcommand.cpp create mode 100644 src/subcommand/fetch_subcommand.hpp create mode 100644 src/subcommand/push_subcommand.cpp create mode 100644 src/subcommand/push_subcommand.hpp create mode 100644 src/subcommand/remote_subcommand.cpp create mode 100644 src/subcommand/remote_subcommand.hpp create mode 100644 src/utils/progress.cpp create mode 100644 src/utils/progress.hpp create mode 100644 src/wrapper/remote_wrapper.cpp create mode 100644 src/wrapper/remote_wrapper.hpp create mode 100644 test/test_remote.py diff --git a/CMakeLists.txt b/CMakeLists.txt index d18cbd9..a9a266b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -50,12 +50,18 @@ set(GIT2CPP_SRC ${GIT2CPP_SOURCE_DIR}/subcommand/clone_subcommand.hpp ${GIT2CPP_SOURCE_DIR}/subcommand/commit_subcommand.cpp ${GIT2CPP_SOURCE_DIR}/subcommand/commit_subcommand.hpp + ${GIT2CPP_SOURCE_DIR}/subcommand/fetch_subcommand.cpp + ${GIT2CPP_SOURCE_DIR}/subcommand/fetch_subcommand.hpp ${GIT2CPP_SOURCE_DIR}/subcommand/init_subcommand.cpp ${GIT2CPP_SOURCE_DIR}/subcommand/init_subcommand.hpp ${GIT2CPP_SOURCE_DIR}/subcommand/log_subcommand.cpp ${GIT2CPP_SOURCE_DIR}/subcommand/log_subcommand.hpp ${GIT2CPP_SOURCE_DIR}/subcommand/merge_subcommand.cpp ${GIT2CPP_SOURCE_DIR}/subcommand/merge_subcommand.hpp + ${GIT2CPP_SOURCE_DIR}/subcommand/push_subcommand.cpp + ${GIT2CPP_SOURCE_DIR}/subcommand/push_subcommand.hpp + ${GIT2CPP_SOURCE_DIR}/subcommand/remote_subcommand.cpp + ${GIT2CPP_SOURCE_DIR}/subcommand/remote_subcommand.hpp ${GIT2CPP_SOURCE_DIR}/subcommand/reset_subcommand.cpp ${GIT2CPP_SOURCE_DIR}/subcommand/reset_subcommand.hpp ${GIT2CPP_SOURCE_DIR}/subcommand/status_subcommand.cpp @@ -68,6 +74,8 @@ set(GIT2CPP_SRC ${GIT2CPP_SOURCE_DIR}/utils/git_exception.hpp ${GIT2CPP_SOURCE_DIR}/utils/output.cpp ${GIT2CPP_SOURCE_DIR}/utils/output.hpp + ${GIT2CPP_SOURCE_DIR}/utils/progress.cpp + ${GIT2CPP_SOURCE_DIR}/utils/progress.hpp ${GIT2CPP_SOURCE_DIR}/utils/terminal_pager.cpp ${GIT2CPP_SOURCE_DIR}/utils/terminal_pager.hpp ${GIT2CPP_SOURCE_DIR}/wrapper/annotated_commit_wrapper.cpp @@ -82,6 +90,8 @@ set(GIT2CPP_SRC ${GIT2CPP_SOURCE_DIR}/wrapper/object_wrapper.hpp ${GIT2CPP_SOURCE_DIR}/wrapper/refs_wrapper.cpp ${GIT2CPP_SOURCE_DIR}/wrapper/refs_wrapper.hpp + ${GIT2CPP_SOURCE_DIR}/wrapper/remote_wrapper.cpp + ${GIT2CPP_SOURCE_DIR}/wrapper/remote_wrapper.hpp ${GIT2CPP_SOURCE_DIR}/wrapper/repository_wrapper.cpp ${GIT2CPP_SOURCE_DIR}/wrapper/repository_wrapper.hpp ${GIT2CPP_SOURCE_DIR}/wrapper/signature_wrapper.cpp diff --git a/src/main.cpp b/src/main.cpp index e8479c8..7b52301 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -10,9 +10,12 @@ #include "subcommand/checkout_subcommand.hpp" #include "subcommand/clone_subcommand.hpp" #include "subcommand/commit_subcommand.hpp" +#include "subcommand/fetch_subcommand.hpp" #include "subcommand/init_subcommand.hpp" #include "subcommand/log_subcommand.hpp" #include "subcommand/merge_subcommand.hpp" +#include "subcommand/push_subcommand.hpp" +#include "subcommand/remote_subcommand.hpp" #include "subcommand/reset_subcommand.hpp" #include "subcommand/status_subcommand.hpp" @@ -35,9 +38,12 @@ int main(int argc, char** argv) checkout_subcommand checkout(lg2_obj, app); clone_subcommand clone(lg2_obj, app); commit_subcommand commit(lg2_obj, app); + fetch_subcommand fetch(lg2_obj, app); reset_subcommand reset(lg2_obj, app); log_subcommand log(lg2_obj, app); merge_subcommand merge(lg2_obj, app); + push_subcommand push(lg2_obj, app); + remote_subcommand remote(lg2_obj, app); app.require_subcommand(/* min */ 0, /* max */ 1); diff --git a/src/subcommand/clone_subcommand.cpp b/src/subcommand/clone_subcommand.cpp index 6c9b803..69b44fa 100644 --- a/src/subcommand/clone_subcommand.cpp +++ b/src/subcommand/clone_subcommand.cpp @@ -2,6 +2,7 @@ #include "../subcommand/clone_subcommand.hpp" #include "../utils/output.hpp" +#include "../utils/progress.hpp" #include "../wrapper/repository_wrapper.hpp" clone_subcommand::clone_subcommand(const libgit2_object&, CLI::App& app) @@ -10,81 +11,11 @@ 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_flag("--bare", m_bare, "Create a bare Git repository."); sub->callback([this]() { this->run(); }); } -namespace -{ - int sideband_progress(const char* str, int len, void*) - { - printf("remote: %.*s", len, str); - fflush(stdout); - return 0; - } - - int fetch_progress(const git_indexer_progress* stats, void* payload) - { - static bool done = false; - - // We need to copy stats into payload even if the fetch is done, - // because the checkout_progress callback will be called with the - // same payload and needs the data to be up do date. - auto* pr = reinterpret_cast(payload); - *pr = *stats; - - if (done) - { - return 0; - } - - int network_percent = pr->total_objects > 0 ? - (100 * pr->received_objects / pr->total_objects) - : 0; - size_t mbytes = pr->received_bytes / (1024*1024); - - std::cout << "Receiving objects: " << std::setw(4) << network_percent - << "% (" << pr->received_objects << "/" << pr->total_objects << "), " - << mbytes << " MiB"; - - if (pr->received_objects == pr->total_objects) - { - std::cout << ", done." << std::endl; - done = true; - } - else - { - std::cout << '\r'; - } - return 0; - } - - void checkout_progress(const char* path, size_t cur, size_t tot, void* payload) - { - static bool done = false; - if (done) - { - return; - } - auto* pr = reinterpret_cast(payload); - int deltas_percent = pr->total_deltas > 0 ? - (100 * pr->indexed_deltas / pr->total_deltas) - : 0; - - std::cout << "Resolving deltas: " << std::setw(4) << deltas_percent - << "% (" << pr->indexed_deltas << "/" << pr->total_deltas << ")"; - if (pr->indexed_deltas == pr->total_deltas) - { - std::cout << ", done." << std::endl; - done = true; - } - else - { - std::cout << '\r'; - } - } -} - void clone_subcommand::run() { git_indexer_progress pd; @@ -94,9 +25,10 @@ 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.sideband_progress = sideband_progress; - clone_opts.fetch_opts.callbacks.transfer_progress = fetch_progress; - clone_opts.fetch_opts.callbacks.payload = &pd; + 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; + clone_opts.bare = m_bare ? 1 : 0; std::string short_name = m_directory; if (m_directory.empty()) diff --git a/src/subcommand/clone_subcommand.hpp b/src/subcommand/clone_subcommand.hpp index bf2a0d7..631cd07 100644 --- a/src/subcommand/clone_subcommand.hpp +++ b/src/subcommand/clone_subcommand.hpp @@ -15,4 +15,5 @@ class clone_subcommand std::string m_repository = {}; std::string m_directory = {}; + bool m_bare = false; }; diff --git a/src/subcommand/fetch_subcommand.cpp b/src/subcommand/fetch_subcommand.cpp new file mode 100644 index 0000000..4d07e1a --- /dev/null +++ b/src/subcommand/fetch_subcommand.cpp @@ -0,0 +1,54 @@ +#include + +#include + +#include "../subcommand/fetch_subcommand.hpp" +#include "../utils/output.hpp" +#include "../utils/progress.hpp" +#include "../wrapper/repository_wrapper.hpp" + +fetch_subcommand::fetch_subcommand(const libgit2_object&, CLI::App& app) +{ + auto* sub = app.add_subcommand("fetch", "Download objects and refs from another repository"); + + sub->add_option("", m_remote_name, "The remote to fetch from") + ->default_val("origin"); + + sub->callback([this]() { this->run(); }); +} + +void fetch_subcommand::run() +{ + auto directory = get_current_git_path(); + auto repo = repository_wrapper::open(directory); + + // Find the remote (default to origin if not specified) + std::string remote_name = m_remote_name.empty() ? "origin" : m_remote_name; + auto remote = repo.find_remote(remote_name); + + git_indexer_progress pd = {0}; + git_fetch_options fetch_opts = GIT_FETCH_OPTIONS_INIT; + fetch_opts.callbacks.sideband_progress = sideband_progress; + fetch_opts.callbacks.transfer_progress = fetch_progress; + fetch_opts.callbacks.payload = &pd; + fetch_opts.callbacks.update_refs = update_refs; + + cursor_hider ch; + + // Perform the fetch + remote.fetch(nullptr, &fetch_opts, "fetch"); + + // Show statistics + const git_indexer_progress* stats = git_remote_stats(remote); + if (stats->local_objects > 0) + { + std::cout << "\rReceived " << stats->indexed_objects << "/" << stats->total_objects + << " objects in " << stats->received_bytes << " bytes (used " + << stats->local_objects << " local objects)" << std::endl; + } + else + { + std::cout << "\rReceived " << stats->indexed_objects << "/" << stats->total_objects + << " objects in " << stats->received_bytes << " bytes" << std::endl; + } +} diff --git a/src/subcommand/fetch_subcommand.hpp b/src/subcommand/fetch_subcommand.hpp new file mode 100644 index 0000000..bc607c1 --- /dev/null +++ b/src/subcommand/fetch_subcommand.hpp @@ -0,0 +1,19 @@ +#pragma once + +#include + +#include + +#include "../utils/common.hpp" + +class fetch_subcommand +{ +public: + + explicit fetch_subcommand(const libgit2_object&, CLI::App& app); + void run(); + +private: + + std::string m_remote_name; +}; diff --git a/src/subcommand/push_subcommand.cpp b/src/subcommand/push_subcommand.cpp new file mode 100644 index 0000000..be04267 --- /dev/null +++ b/src/subcommand/push_subcommand.cpp @@ -0,0 +1,54 @@ +#include + +#include + +#include "../subcommand/push_subcommand.hpp" +#include "../utils/progress.hpp" +#include "../wrapper/repository_wrapper.hpp" + +push_subcommand::push_subcommand(const libgit2_object&, CLI::App& app) +{ + auto* sub = app.add_subcommand("push", "Update remote refs along with associated objects"); + + sub->add_option("", m_remote_name, "The remote to push to") + ->default_val("origin"); + + sub->add_option("", m_refspecs, "The refspec(s) to push"); + + sub->callback([this]() { this->run(); }); +} + +void push_subcommand::run() +{ + auto directory = get_current_git_path(); + auto repo = repository_wrapper::open(directory); + + std::string remote_name = m_remote_name.empty() ? "origin" : m_remote_name; + auto remote = repo.find_remote(remote_name); + + git_push_options push_opts = GIT_PUSH_OPTIONS_INIT; + push_opts.callbacks.push_transfer_progress = push_transfer_progress; + push_opts.callbacks.push_update_reference = push_update_reference; + + if (m_refspecs.empty()) + { + try + { + auto head_ref = repo.head(); + std::string short_name = head_ref.short_name(); + std::string refspec = "refs/heads/" + short_name; + m_refspecs.push_back(refspec); + } + catch (...) + { + std::cerr << "Could not determine current branch to push." << std::endl; + return; + } + } + git_strarray_wrapper refspecs_wrapper(m_refspecs); + git_strarray* refspecs_ptr = nullptr; + refspecs_ptr = refspecs_wrapper; + + remote.push(refspecs_ptr, &push_opts); + std::cout << "Pushed to " << remote_name << std::endl; +} diff --git a/src/subcommand/push_subcommand.hpp b/src/subcommand/push_subcommand.hpp new file mode 100644 index 0000000..07c301e --- /dev/null +++ b/src/subcommand/push_subcommand.hpp @@ -0,0 +1,21 @@ +#pragma once + +#include +#include + +#include + +#include "../utils/common.hpp" + +class push_subcommand +{ +public: + + explicit push_subcommand(const libgit2_object&, CLI::App& app); + void run(); + +private: + + std::string m_remote_name; + std::vector m_refspecs; +}; diff --git a/src/subcommand/remote_subcommand.cpp b/src/subcommand/remote_subcommand.cpp new file mode 100644 index 0000000..f76ad70 --- /dev/null +++ b/src/subcommand/remote_subcommand.cpp @@ -0,0 +1,194 @@ +#include +#include + +#include "../subcommand/remote_subcommand.hpp" +#include "../wrapper/repository_wrapper.hpp" + +remote_subcommand::remote_subcommand(const libgit2_object&, CLI::App& app) +{ + m_subcommand = app.add_subcommand("remote", "Manage set of tracked repositories"); + + m_subcommand->add_option("operation", m_operation, "Operation: add, remove, rename, set-url, show") + ->check(CLI::IsMember({"add", "remove", "rm", "rename", "set-url", "show"})); + + m_subcommand->add_flag("-v,--verbose", m_verbose_flag, "Be verbose"); + m_subcommand->add_flag("--push", m_push_flag, "Set push URL instead of fetch URL"); + + // Allow positional arguments after operation + m_subcommand->allow_extras(); + + m_subcommand->callback([this]() { this->run(); }); +} + +void remote_subcommand::run() +{ + auto directory = get_current_git_path(); + auto repo = repository_wrapper::open(directory); + + // Get extra positional arguments + auto extras = m_subcommand->remaining(); + + // Parse positional arguments based on operation + if (m_operation == "add") + { + if (extras.size() == 2) + { + m_remote_name = extras[0]; + m_url = extras[1]; + } + run_add(repo); + } + else if (m_operation == "remove" || m_operation == "rm") + { + if (extras.size() == 1) + { + m_remote_name = extras[0]; + } + run_remove(repo); + } + else if (m_operation == "rename") + { + if (extras.size() == 2) + { + m_old_name = extras[0]; + m_new_name = extras[1]; + } + run_rename(repo); + } + else if (m_operation == "set-url") + { + // Handle --push flag before arguments + size_t arg_idx = 0; + if (extras.size() > 0 && extras[0] == "--push") + { + m_push_flag = true; + arg_idx = 1; + } + if (extras.size() >= arg_idx + 2) + { + m_remote_name = extras[arg_idx]; + m_new_name = extras[arg_idx + 1]; + run_seturl(repo); + } + else if (m_remote_name.empty() || m_new_name.empty()) + { + throw std::runtime_error("remote set-url requires both name and new URL"); + } + else + { + run_seturl(repo); + } + } + else if (m_operation.empty() || m_operation == "show") + { + if (extras.size() >= 1) + { + m_remote_name = extras[0]; + } + run_show(repo); + } +} + +void remote_subcommand::run_add(repository_wrapper& repo) +{ + if (m_remote_name.empty()) + { + throw std::runtime_error("usage: git remote add "); // TODO: add [] when implemented + } + repo.create_remote(m_remote_name, m_url); +} + +void remote_subcommand::run_remove(repository_wrapper& repo) +{ + if (m_remote_name.empty()) + { + throw std::runtime_error("usage: git remote remove "); + } + repo.delete_remote(m_remote_name); +} + +void remote_subcommand::run_rename(repository_wrapper& repo) +{ + if (m_old_name.empty()) + { + throw std::runtime_error("usage: git remote rename "); // TODO: add [--[no-]progress] when implemented + } + repo.rename_remote(m_old_name, m_new_name); +} + +void remote_subcommand::run_seturl(repository_wrapper& repo) +{ + if (m_remote_name.empty() || m_new_name.empty()) + { + throw std::runtime_error("remote set-url requires both name and new URL"); + } + repo.set_remote_url(m_remote_name, m_new_name, m_push_flag); +} + +void remote_subcommand::run_show(const repository_wrapper& repo) +{ + auto remotes = repo.list_remotes(); + + if (m_remote_name.empty()) + { + // Show all remotes + for (const auto& name : remotes) + { + if (m_verbose_flag) + { + auto remote = repo.find_remote(name); + auto fetch_url = remote.url(); + auto push_url = remote.pushurl(); + + if (!fetch_url.empty()) + { + std::cout << name << "\t" << fetch_url << " (fetch)" << std::endl; + } + if (!push_url.empty()) + { + std::cout << name << "\t" << push_url << " (push)" << std::endl; + } + else if (!fetch_url.empty()) + { + std::cout << name << "\t" << fetch_url << " (push)" << std::endl; + } + } + else + { + std::cout << name << std::endl; + } + } + } + else + { + // Show specific remote + auto remote = repo.find_remote(m_remote_name); + std::cout << "* remote " << m_remote_name << std::endl; + + auto fetch_url = remote.url(); + if (!fetch_url.empty()) + { + std::cout << " Fetch URL: " << fetch_url << std::endl; + } + + auto push_url = remote.pushurl(); + if (!push_url.empty()) + { + std::cout << " Push URL: " << push_url << std::endl; + } + else if (!fetch_url.empty()) + { + std::cout << " Push URL: " << fetch_url << std::endl; + } + + auto refspecs = remote.refspecs(); + if (!refspecs.empty()) + { + std::cout << " HEAD branch: (not yet implemented)" << std::endl; + for (const auto& refspec : refspecs) + { + std::cout << " " << refspec << std::endl; + } + } + } +} diff --git a/src/subcommand/remote_subcommand.hpp b/src/subcommand/remote_subcommand.hpp new file mode 100644 index 0000000..b6f3bf2 --- /dev/null +++ b/src/subcommand/remote_subcommand.hpp @@ -0,0 +1,34 @@ +#pragma once + +#include + +#include + +#include "../utils/common.hpp" +#include "../wrapper/repository_wrapper.hpp" + +class remote_subcommand +{ +public: + + explicit remote_subcommand(const libgit2_object&, CLI::App& app); + void run(); + +private: + + void run_list(const repository_wrapper& repo); + void run_add(repository_wrapper& repo); + void run_remove(repository_wrapper& repo); + void run_rename(repository_wrapper& repo); + void run_seturl(repository_wrapper& repo); + void run_show(const repository_wrapper& repo); + + CLI::App* m_subcommand = nullptr; + std::string m_operation; + std::string m_remote_name; + std::string m_url; + std::string m_old_name; + std::string m_new_name; + bool m_verbose_flag = false; + bool m_push_flag = false; +}; diff --git a/src/utils/progress.cpp b/src/utils/progress.cpp new file mode 100644 index 0000000..bdb3a23 --- /dev/null +++ b/src/utils/progress.cpp @@ -0,0 +1,155 @@ +#include +#include +#include + +#include "../utils/progress.hpp" + +int sideband_progress(const char* str, int len, void*) +{ + printf("remote: %.*s", len, str); + fflush(stdout); + return 0; +} + +int fetch_progress(const git_indexer_progress* stats, void* payload) +{ + static bool done = false; + + // We need to copy stats into payload even if the fetch is done, + // because the checkout_progress callback will be called with the + // same payload and needs the data to be up do date. + auto* pr = reinterpret_cast(payload); + *pr = *stats; + + if (done) + { + return 0; + } + + int network_percent = pr->total_objects > 0 ? + (100 * pr->received_objects / pr->total_objects) + : 0; + size_t kbytes = pr->received_bytes / 1024; + size_t mbytes = kbytes / 1024; + + std::cout << "Receiving objects: " << std::setw(4) << network_percent + << "% (" << pr->received_objects << "/" << pr->total_objects << "), "; + if (mbytes != 0) + { + std::cout << mbytes << " MiB"; + } + else if (kbytes != 0) + { + std::cout << kbytes << " KiB"; + } + else + { + std::cout << pr->received_bytes << " bytes"; + } + // TODO: compute speed + + if (pr->received_objects == pr->total_objects) + { + std::cout << ", done." << std::endl; + done = true; + } + else + { + std::cout << '\r'; + } + return 0; +} + +void checkout_progress(const char* path, size_t cur, size_t tot, void* payload) +{ + static bool done = false; + if (done) + { + return; + } + auto* pr = reinterpret_cast(payload); + int deltas_percent = pr->total_deltas > 0 ? + (100 * pr->indexed_deltas / pr->total_deltas) + : 0; + + std::cout << "Resolving deltas: " << std::setw(4) << deltas_percent + << "% (" << pr->indexed_deltas << "/" << pr->total_deltas << ")"; + if (pr->indexed_deltas == pr->total_deltas) + { + std::cout << ", done." << std::endl; + done = true; + } + else + { + std::cout << '\r'; + } +} + +int update_refs(const char* refname, const git_oid* a, const git_oid* b, git_refspec*, void*) +{ + char a_str[GIT_OID_SHA1_HEXSIZE+1], b_str[GIT_OID_SHA1_HEXSIZE+1]; + + git_oid_fmt(b_str, b); + b_str[GIT_OID_SHA1_HEXSIZE] = '\0'; + + if (git_oid_is_zero(a)) + { + std::string n, name, ref; + const size_t last_slash_idx = std::string_view(refname).find_last_of('/'); + name = std::string_view(refname).substr(last_slash_idx + 1, -1); + if (std::string_view(refname).find("remote") != std::string::npos) // maybe will string_view need the size of the string + { + n = " * [new branch] "; + auto new_refname = std::string_view(refname).substr(0, last_slash_idx - 1); + const size_t second_to_last_slash_idx = std::string_view(new_refname).find_last_of('/'); + ref = std::string_view(refname).substr(second_to_last_slash_idx + 1, -1); + } + else if (std::string_view(refname).find("tags") != std::string::npos) + { + n = " * [new tag] "; + ref = name; + } + else + { + // could it be something else ? + } + std::cout << n << name << "\t-> " << ref << std::endl; + } + else + { + git_oid_fmt(a_str, a); + a_str[GIT_OID_SHA1_HEXSIZE] = '\0'; + + std::cout << "[updated] " + << std::string(a_str, 10) + << ".." + << std::string(b_str, 10) + << " " << refname << std::endl; + } + + return 0; +} + +int push_transfer_progress(unsigned int current, unsigned int total, size_t bytes, void*) +{ + if (total > 0) + { + int percent = (100 * current) / total; + std::cout << "Writing objects: " << percent << "% (" << current + << "/" << total << "), " << bytes << " bytes\r"; + } + return 0; +} + +int push_update_reference(const char* refname, const char* status, void*) +{ + if (status) + { + std::cout << " " << refname << " " << status << std::endl; + } + else + { + std::cout << " " << refname << std::endl; + } + return 0; +} diff --git a/src/utils/progress.hpp b/src/utils/progress.hpp new file mode 100644 index 0000000..861c8d9 --- /dev/null +++ b/src/utils/progress.hpp @@ -0,0 +1,10 @@ +#pragma once + +#include + +int sideband_progress(const char* str, int len, void*); +int fetch_progress(const git_indexer_progress* stats, void* payload); +void checkout_progress(const char* path, size_t cur, size_t tot, void* payload); +int update_refs(const char* refname, const git_oid* a, const git_oid* b, git_refspec*, void*); +int push_transfer_progress(unsigned int current, unsigned int total, size_t bytes, void*); +int push_update_reference(const char* refname, const char* status, void*); diff --git a/src/wrapper/remote_wrapper.cpp b/src/wrapper/remote_wrapper.cpp new file mode 100644 index 0000000..08420ca --- /dev/null +++ b/src/wrapper/remote_wrapper.cpp @@ -0,0 +1,63 @@ +#include +#include + +#include + +#include "../utils/git_exception.hpp" +#include "../wrapper/remote_wrapper.hpp" + +remote_wrapper::remote_wrapper(git_remote* remote) + : base_type(remote) +{ +} + +remote_wrapper::~remote_wrapper() +{ + git_remote_free(p_resource); + p_resource = nullptr; +} + +std::string_view remote_wrapper::name() const +{ + const char* out = git_remote_name(*this); + return out ? std::string_view(out) : std::string_view(); +} + +std::string_view remote_wrapper::url() const +{ + const char* out = git_remote_url(*this); + return out ? std::string_view(out) : std::string_view(); +} + +std::string_view remote_wrapper::pushurl() const +{ + const char* out = git_remote_pushurl(*this); + return out ? std::string_view(out) : std::string_view(); +} + +std::vector remote_wrapper::refspecs() const +{ + git_strarray refspecs = {0}; + std::vector result; + + if (git_remote_get_fetch_refspecs(&refspecs, *this) == 0) + { + for (size_t i = 0; i < refspecs.count; ++i) + { + result.emplace_back(refspecs.strings[i]); + } + git_strarray_dispose(&refspecs); + } + + return result; +} + +void remote_wrapper::fetch(const git_strarray* refspecs, const git_fetch_options* opts, const char* reflog_message) +{ + throw_if_error(git_remote_fetch(*this, refspecs, opts, reflog_message)); +} + +void remote_wrapper::push(const git_strarray* refspecs, const git_push_options* opts) +{ + throw_if_error(git_remote_push(*this, refspecs, opts)); +} diff --git a/src/wrapper/remote_wrapper.hpp b/src/wrapper/remote_wrapper.hpp new file mode 100644 index 0000000..1fa1632 --- /dev/null +++ b/src/wrapper/remote_wrapper.hpp @@ -0,0 +1,36 @@ +#pragma once + +#include +#include +#include + +#include + +#include "../wrapper/wrapper_base.hpp" + +class remote_wrapper : public wrapper_base +{ +public: + + using base_type = wrapper_base; + + ~remote_wrapper(); + + remote_wrapper(remote_wrapper&&) = default; + remote_wrapper& operator=(remote_wrapper&&) = default; + + std::string_view name() const; + std::string_view url() const; + std::string_view pushurl() const; + + std::vector refspecs() const; + + void fetch(const git_strarray* refspecs, const git_fetch_options* opts, const char* reflog_message); + void push(const git_strarray* refspecs, const git_push_options* opts); + +private: + + explicit remote_wrapper(git_remote* remote); + + friend class repository_wrapper; +}; diff --git a/src/wrapper/repository_wrapper.cpp b/src/wrapper/repository_wrapper.cpp index fcbd365..15cdfa8 100644 --- a/src/wrapper/repository_wrapper.cpp +++ b/src/wrapper/repository_wrapper.cpp @@ -1,8 +1,12 @@ +#include + #include "../utils/git_exception.hpp" #include "../wrapper/index_wrapper.hpp" #include "../wrapper/object_wrapper.hpp" #include "../wrapper/commit_wrapper.hpp" +#include "../wrapper/remote_wrapper.hpp" #include +#include #include "../wrapper/repository_wrapper.hpp" repository_wrapper::~repository_wrapper() @@ -238,3 +242,67 @@ void repository_wrapper::checkout_tree(const object_wrapper& target, const git_c { throw_if_error(git_checkout_tree(*this, target, &opts)); } + +// Remotes + +remote_wrapper repository_wrapper::find_remote(std::string_view name) const +{ + git_remote* remote = nullptr; + throw_if_error(git_remote_lookup(&remote, *this, name.data())); + return remote_wrapper(remote); +} + +remote_wrapper repository_wrapper::create_remote(std::string_view name, std::string_view url) +{ + git_remote* remote = nullptr; + throw_if_error(git_remote_create(&remote, *this, name.data(), url.data())); + return remote_wrapper(remote); +} + +void repository_wrapper::delete_remote(std::string_view name) +{ + throw_if_error(git_remote_delete(*this, name.data())); +} + +void repository_wrapper::rename_remote(std::string_view old_name, std::string_view new_name) +{ + git_strarray problems = {0}; + int error = git_remote_rename(&problems, *this, old_name.data(), new_name.data()); + if (error != 0) + { + for (size_t i = 0; i < problems.count; ++i) + { + std::cerr << problems.strings[i] << std::endl; + } + git_strarray_dispose(&problems); + throw_if_error(error); + } + git_strarray_dispose(&problems); +} + +void repository_wrapper::set_remote_url(std::string_view name, std::string_view url, bool push) +{ + if (push) + { + throw_if_error(git_remote_set_pushurl(*this, name.data(), url.data())); + } + else + { + throw_if_error(git_remote_set_url(*this, name.data(), url.data())); + } +} + +std::vector repository_wrapper::list_remotes() const +{ + git_strarray remotes = {0}; + throw_if_error(git_remote_list(&remotes, *this)); + + std::vector result; + for (size_t i = 0; i < remotes.count; ++i) + { + result.emplace_back(remotes.strings[i]); + } + + git_strarray_dispose(&remotes); + return result; +} diff --git a/src/wrapper/repository_wrapper.hpp b/src/wrapper/repository_wrapper.hpp index 99e36ae..6b3e55a 100644 --- a/src/wrapper/repository_wrapper.hpp +++ b/src/wrapper/repository_wrapper.hpp @@ -13,6 +13,7 @@ #include "../wrapper/index_wrapper.hpp" #include "../wrapper/object_wrapper.hpp" #include "../wrapper/refs_wrapper.hpp" +#include "../wrapper/remote_wrapper.hpp" #include "../wrapper/signature_wrapper.hpp" #include "../wrapper/wrapper_base.hpp" @@ -73,6 +74,14 @@ class repository_wrapper : public wrapper_base // Trees void checkout_tree(const object_wrapper& target, const git_checkout_options opts); + // Remotes + remote_wrapper find_remote(std::string_view name) const; + remote_wrapper create_remote(std::string_view name, std::string_view url); + void delete_remote(std::string_view name); + void rename_remote(std::string_view old_name, std::string_view new_name); + void set_remote_url(std::string_view name, std::string_view url, bool push = false); + std::vector list_remotes() const; + private: repository_wrapper() = default; diff --git a/test/test_clone.py b/test/test_clone.py index 7ada28e..a28a058 100644 --- a/test/test_clone.py +++ b/test/test_clone.py @@ -5,11 +5,23 @@ def test_clone(git2cpp_path, tmp_path, run_in_tmp_path): - url = 'https://github.com/xtensor-stack/xtl.git' + url = "https://github.com/xtensor-stack/xtl.git" - clone_cmd = [git2cpp_path, 'clone', url] - p_clone = subprocess.run(clone_cmd, capture_output=True, cwd = tmp_path, text=True) + clone_cmd = [git2cpp_path, "clone", url] + 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 os.path.exists(os.path.join(tmp_path, "xtl")) + assert os.path.exists(os.path.join(tmp_path, "xtl/include")) + + +def test_clone_is_bare(git2cpp_path, tmp_path, run_in_tmp_path): + url = "https://github.com/xtensor-stack/xtl.git" + + clone_cmd = [git2cpp_path, "clone", "--bare", url] + p_clone = subprocess.run(clone_cmd, capture_output=True, cwd=tmp_path, text=True) + assert p_clone.returncode == 0 + + status_cmd = [git2cpp_path, "status"] + p_status = subprocess.run(status_cmd, capture_output=True, cwd=tmp_path, text=True) + assert p_status.returncode != 0 diff --git a/test/test_remote.py b/test/test_remote.py new file mode 100644 index 0000000..56f6dc4 --- /dev/null +++ b/test/test_remote.py @@ -0,0 +1,383 @@ +import subprocess + +import pytest + +repo_url = "https://github.com/user/repo.git" + + +def test_remote_list_empty(git2cpp_path, tmp_path, run_in_tmp_path): + """Test listing remotes in a repo with no remotes.""" + # Initialize a repo + p_init = subprocess.run([git2cpp_path, "init"], capture_output=True, check=True) + assert p_init.returncode == 0 + + cmd = [git2cpp_path, "remote"] + p = subprocess.run(cmd, capture_output=True, text=True) + assert p.returncode == 0 + assert p.stdout == "" # No remotes yet + + +def test_remote_add(git2cpp_path, tmp_path, run_in_tmp_path): + """Test adding a remote.""" + p_init = subprocess.run([git2cpp_path, "init"], capture_output=True, check=True) + assert p_init.returncode == 0 + + missing_cmd = [git2cpp_path, "remote", "add", "origin"] + p_missing = subprocess.run(missing_cmd, capture_output=True, text=True) + assert p_missing.returncode != 0 + assert "usage: git remote add " in p_missing.stderr + + add_cmd = [git2cpp_path, "remote", "add", "origin", repo_url] + p_add = subprocess.run(add_cmd, capture_output=True, text=True) + assert p_add.returncode == 0 + + # Verify remote was added + list_cmd = [git2cpp_path, "remote"] + p_list = subprocess.run(list_cmd, capture_output=True, text=True) + assert p_list.returncode == 0 + assert "origin" in p_list.stdout + + +def test_remote_add_multiple(git2cpp_path, tmp_path, run_in_tmp_path): + """Test adding multiple remotes.""" + p_init = subprocess.run([git2cpp_path, "init"], capture_output=True, check=True) + assert p_init.returncode == 0 + + add_origin_cmd = [git2cpp_path, "remote", "add", "origin", repo_url] + p_add_origin = subprocess.run(add_origin_cmd, capture_output=True, check=True) + assert p_add_origin.returncode == 0 + add_upstream_cmd = [ + git2cpp_path, + "remote", + "add", + "upstream", + "https://github.com/upstream/repo.git", + ] + p_add_upstream = subprocess.run(add_upstream_cmd, capture_output=True, check=True) + assert p_add_upstream.returncode == 0 + + list_cmd = [git2cpp_path, "remote"] + p_list = subprocess.run(list_cmd, capture_output=True, text=True) + assert p_list.returncode == 0 + output = p_list.stdout.strip() + assert "origin" in output + assert "upstream" in output + + +@pytest.mark.parametrize("remove", ["rm", "remove"]) +def test_remote_remove(git2cpp_path, tmp_path, run_in_tmp_path, remove): + """Test removing a remote.""" + p_init = subprocess.run([git2cpp_path, "init"], capture_output=True, check=True) + assert p_init.returncode == 0 + + add_cmd = [git2cpp_path, "remote", "add", "origin", repo_url] + p_add = subprocess.run(add_cmd, capture_output=True, check=True) + assert p_add.returncode == 0 + + # Remove the remote + remove_cmd = [git2cpp_path, "remote", remove, "origin"] + p_remove = subprocess.run(remove_cmd, capture_output=True, text=True) + assert p_remove.returncode == 0 + + # Verify remote was removed + list_cmd = [git2cpp_path, "remote"] + p_list = subprocess.run(list_cmd, capture_output=True, text=True) + assert p_list.returncode == 0 + assert "origin" not in p_list.stdout + + +def test_remote_rename(git2cpp_path, tmp_path, run_in_tmp_path): + """Test renaming a remote.""" + p_init = subprocess.run([git2cpp_path, "init"], capture_output=True, check=True) + assert p_init.returncode == 0 + + add_cmd = [git2cpp_path, "remote", "add", "origin", repo_url] + p_add = subprocess.run(add_cmd, capture_output=True, check=True) + assert p_add.returncode == 0 + + # Rename the remote + rename_cmd = [git2cpp_path, "remote", "rename", "origin", "upstream"] + p_rename = subprocess.run(rename_cmd, capture_output=True, text=True) + assert p_rename.returncode == 0 + + # Verify remote was renamed + list_cmd = [git2cpp_path, "remote"] + p_list = subprocess.run(list_cmd, capture_output=True, text=True) + assert p_list.returncode == 0 + assert "origin" not in p_list.stdout + assert "upstream" in p_list.stdout + + +def test_remote_set_url(git2cpp_path, tmp_path, run_in_tmp_path): + """Test setting remote URL.""" + p_init = subprocess.run([git2cpp_path, "init"], capture_output=True, check=True) + assert p_init.returncode == 0 + + add_cmd = [git2cpp_path, "remote", "add", "origin", repo_url] + p_add = subprocess.run(add_cmd, capture_output=True, check=True) + assert p_add.returncode == 0 + + # Change the URL + new_url = "https://github.com/user/newrepo.git" + set_url_cmd = [git2cpp_path, "remote", "set-url", "origin", new_url] + p_set_url = subprocess.run(set_url_cmd, capture_output=True, text=True) + assert p_set_url.returncode == 0 + + # Verify URL was changed + show_cmd = [git2cpp_path, "remote", "show", "origin"] + p_show = subprocess.run(show_cmd, capture_output=True, text=True) + assert p_show.returncode == 0 + assert new_url in p_show.stdout + + +def test_remote_set_push_url(git2cpp_path, tmp_path, run_in_tmp_path): + """Test setting remote push URL.""" + p_init = subprocess.run([git2cpp_path, "init"], capture_output=True, check=True) + assert p_init.returncode == 0 + + subprocess.run( + [git2cpp_path, "remote", "add", "origin", repo_url], + capture_output=True, + check=True, + ) + + # Set push URL + push_url = "https://github.com/user/pushrepo.git" + cmd = [git2cpp_path, "remote", "set-url", "--push", "origin", push_url] + p = subprocess.run(cmd, capture_output=True, text=True) + assert p.returncode == 0 + + # Verify push URL was set + show_cmd = [git2cpp_path, "remote", "show", "origin"] + p_show = subprocess.run(show_cmd, capture_output=True, text=True) + assert p_show.returncode == 0 + assert push_url in p_show.stdout + + +def test_remote_show(git2cpp_path, tmp_path, run_in_tmp_path): + """Test showing remote details.""" + p_init = subprocess.run([git2cpp_path, "init"], capture_output=True, check=True) + assert p_init.returncode == 0 + + subprocess.run( + [git2cpp_path, "remote", "add", "origin", repo_url], + capture_output=True, + check=True, + ) + + cmd = [git2cpp_path, "remote", "show", "origin"] + p = subprocess.run(cmd, capture_output=True, text=True) + assert p.returncode == 0 + assert "origin" in p.stdout + assert repo_url in p.stdout + + +def test_remote_show_verbose(git2cpp_path, tmp_path, run_in_tmp_path): + """Test showing remotes with verbose flag.""" + p_init = subprocess.run([git2cpp_path, "init"], capture_output=True, check=True) + assert p_init.returncode == 0 + + subprocess.run( + [git2cpp_path, "remote", "add", "origin", repo_url], + capture_output=True, + check=True, + ) + + cmd = [git2cpp_path, "remote", "-v"] + p = subprocess.run(cmd, capture_output=True, text=True) + assert p.returncode == 0 + assert "origin" in p.stdout + assert repo_url in p.stdout + assert "(fetch)" in p.stdout or "(push)" in p.stdout + + +def test_remote_show_all_verbose(git2cpp_path, tmp_path, run_in_tmp_path): + """Test showing all remotes with verbose flag.""" + p_init = subprocess.run([git2cpp_path, "init"], capture_output=True, check=True) + assert p_init.returncode == 0 + + add_origin_cmd = [git2cpp_path, "remote", "add", "origin", repo_url] + p_add_origin = subprocess.run(add_origin_cmd, capture_output=True, check=True) + assert p_add_origin.returncode == 0 + add_upstream_cmd = [ + git2cpp_path, + "remote", + "add", + "upstream", + "https://github.com/upstream/repo.git", + ] + p_add_upstream = subprocess.run(add_upstream_cmd, capture_output=True, check=True) + assert p_add_upstream.returncode == 0 + + show_cmd = [git2cpp_path, "remote", "show", "-v"] + p_show = subprocess.run(show_cmd, capture_output=True, text=True) + assert p_show.returncode == 0 + assert "origin" in p_show.stdout + assert "upstream" in p_show.stdout + + +def test_remote_error_on_duplicate_add(git2cpp_path, tmp_path, run_in_tmp_path): + """Test error when adding duplicate remote.""" + p_init = subprocess.run([git2cpp_path, "init"], capture_output=True, check=True) + assert p_init.returncode == 0 + + add_cmd = [git2cpp_path, "remote", "add", "origin", repo_url] + p_add = subprocess.run(add_cmd, capture_output=True, check=True) + assert p_add.returncode == 0 + + # Try to add duplicate + add_dup_cmd = [ + git2cpp_path, + "remote", + "add", + "origin", + "https://github.com/user/other.git", + ] + p_add_dup = subprocess.run(add_dup_cmd, capture_output=True, text=True) + assert p_add_dup.returncode != 0 + + +def test_remote_error_on_remove_nonexistent(git2cpp_path, tmp_path, run_in_tmp_path): + """Test error when removing non-existent remote.""" + p_init = subprocess.run([git2cpp_path, "init"], capture_output=True, check=True) + assert p_init.returncode == 0 + + cmd = [git2cpp_path, "remote", "remove", "nonexistent"] + p = subprocess.run(cmd, capture_output=True, text=True) + assert p.returncode != 0 + + +def test_remote_error_on_rename_nonexistent(git2cpp_path, tmp_path, run_in_tmp_path): + """Test error when renaming non-existent remote.""" + p_init = subprocess.run([git2cpp_path, "init"], capture_output=True, check=True) + assert p_init.returncode == 0 + + cmd = [git2cpp_path, "remote", "rename", "nonexistent", "new"] + p = subprocess.run(cmd, capture_output=True, text=True) + assert p.returncode != 0 + + +def test_remote_error_on_show_nonexistent(git2cpp_path, tmp_path, run_in_tmp_path): + """Test error when showing non-existent remote.""" + p_init = subprocess.run([git2cpp_path, "init"], capture_output=True, check=True) + assert p_init.returncode == 0 + + cmd = [git2cpp_path, "remote", "show", "nonexistent"] + p = subprocess.run(cmd, capture_output=True, text=True) + assert p.returncode != 0 + + +@pytest.fixture +def repo_with_remote(git2cpp_path, tmp_path, run_in_tmp_path): + """Fixture that creates a repo with a remote pointing to a local bare repo.""" + # Create a bare repo to use as remote + remote_path = tmp_path / "remote_repo" + remote_path.mkdir() + init_cmd = [git2cpp_path, "init", "--bare", str(remote_path)] + p_init = subprocess.run(init_cmd, capture_output=True, check=True) + assert p_init.returncode == 0 + + # Create a regular repo + local_path = tmp_path / "local_repo" + local_path.mkdir() + + # Initialize repo in the directory + p_init_2 = subprocess.run( + [git2cpp_path, "init"], capture_output=True, check=True, cwd=local_path + ) + assert p_init_2.returncode == 0 + + # Add remote + add_cmd = [git2cpp_path, "remote", "add", "origin", str(remote_path)] + p_add = subprocess.run(add_cmd, capture_output=True, check=True, cwd=local_path) + assert p_add.returncode == 0 + + return local_path, remote_path + + +def test_fetch_from_remote(git2cpp_path, repo_with_remote): + """Test fetching from a remote.""" + local_path, remote_path = repo_with_remote + + # Note: This is a bare repo with no refs, so fetch will fail gracefully + # For now, just test that fetch command runs (it will fail gracefully if no refs) + cmd = [git2cpp_path, "fetch", "origin"] + p = subprocess.run(cmd, capture_output=True, text=True, cwd=local_path) + # Fetch might succeed (empty) or fail (no refs), but shouldn't crash + assert p.returncode in [0, 1] # 0 for success, 1 for no refs/error + + +def test_fetch_default_origin(git2cpp_path, repo_with_remote): + """Test fetching with default origin.""" + local_path, remote_path = repo_with_remote + + cmd = [git2cpp_path, "fetch"] + p = subprocess.run(cmd, capture_output=True, text=True, cwd=local_path) + # Fetch might succeed (empty) or fail (no refs), but shouldn't crash + assert p.returncode in [0, 1] + + +def test_remote_in_cloned_repo(xtl_clone, git2cpp_path, tmp_path): + """Test that cloned repos have remotes configured.""" + assert (tmp_path / "xtl").exists() + xtl_path = tmp_path / "xtl" + + cmd = [git2cpp_path, "remote"] + p = subprocess.run(cmd, capture_output=True, cwd=xtl_path, text=True) + assert p.returncode == 0 + assert "origin" in p.stdout + + +def test_remote_show_in_cloned_repo(xtl_clone, git2cpp_path, tmp_path): + """Test showing remote in cloned repo.""" + assert (tmp_path / "xtl").exists() + xtl_path = tmp_path / "xtl" + + cmd = [git2cpp_path, "remote", "show", "origin"] + p = subprocess.run(cmd, capture_output=True, cwd=xtl_path, text=True) + assert p.returncode == 0 + assert "origin" in p.stdout + # Should contain URL information + assert "http" in p.stdout or "git" in p.stdout or "https" in p.stdout + + +def test_push_local(xtl_clone, git_config, git2cpp_path, tmp_path, monkeypatch): + """Test setting push on a local remote.""" + 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, check=True, cwd=xtl_path + ) + assert p_checkout.returncode == 0 + + p = xtl_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) + assert p_add.returncode == 0 + + cmd_commit = [git2cpp_path, "commit", "-m", "test commit"] + p_commit = subprocess.run(cmd_commit, cwd=xtl_path, text=True) + assert p_commit.returncode == 0 + + url = "https://github.com/xtensor-stack/xtl.git" + local_path = tmp_path / "local_repo" + clone_cmd = [git2cpp_path, "clone", "--bare", url, local_path] + p_clone = subprocess.run(clone_cmd, capture_output=True, cwd=tmp_path, text=True) + assert p_clone.returncode == 0 + + add_cmd = [git2cpp_path, "remote", "add", "local_repo", str(local_path)] + p_add = subprocess.run(add_cmd, capture_output=True, check=True, cwd=xtl_path) + assert p_add.returncode == 0 + + cmd_push = [git2cpp_path, "push", "local_repo"] # "foregone" + p_push = subprocess.run(cmd_push, capture_output=True, check=True, cwd=xtl_path) + assert p_push.returncode == 0 + + list_cmd = [git2cpp_path, "branch"] + p_list = subprocess.run(list_cmd, capture_output=True, cwd=local_path, text=True) + assert p_list.returncode == 0 + assert "foregone" in p_list.stdout From 6510681cf86800dc865a5af1eae3d246ce576a83 Mon Sep 17 00:00:00 2001 From: Johan Mabille Date: Fri, 12 Dec 2025 10:48:28 +0100 Subject: [PATCH 020/116] Implemented rev-parse -is-bare-repository (#63) --- CMakeLists.txt | 2 ++ src/main.cpp | 2 ++ src/subcommand/revparse_subcommand.cpp | 28 ++++++++++++++++++++++++++ src/subcommand/revparse_subcommand.hpp | 18 +++++++++++++++++ src/wrapper/repository_wrapper.cpp | 5 +++++ src/wrapper/repository_wrapper.hpp | 2 ++ test/test_revparse.py | 16 +++++++++++++++ 7 files changed, 73 insertions(+) create mode 100644 src/subcommand/revparse_subcommand.cpp create mode 100644 src/subcommand/revparse_subcommand.hpp create mode 100644 test/test_revparse.py diff --git a/CMakeLists.txt b/CMakeLists.txt index a9a266b..1165dbd 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -64,6 +64,8 @@ set(GIT2CPP_SRC ${GIT2CPP_SOURCE_DIR}/subcommand/remote_subcommand.hpp ${GIT2CPP_SOURCE_DIR}/subcommand/reset_subcommand.cpp ${GIT2CPP_SOURCE_DIR}/subcommand/reset_subcommand.hpp + ${GIT2CPP_SOURCE_DIR}/subcommand/revparse_subcommand.cpp + ${GIT2CPP_SOURCE_DIR}/subcommand/revparse_subcommand.hpp ${GIT2CPP_SOURCE_DIR}/subcommand/status_subcommand.cpp ${GIT2CPP_SOURCE_DIR}/subcommand/status_subcommand.hpp ${GIT2CPP_SOURCE_DIR}/utils/ansi_code.cpp diff --git a/src/main.cpp b/src/main.cpp index 7b52301..bbdbcef 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -18,6 +18,7 @@ #include "subcommand/remote_subcommand.hpp" #include "subcommand/reset_subcommand.hpp" #include "subcommand/status_subcommand.hpp" +#include "subcommand/revparse_subcommand.hpp" int main(int argc, char** argv) { @@ -44,6 +45,7 @@ int main(int argc, char** argv) merge_subcommand merge(lg2_obj, app); push_subcommand push(lg2_obj, app); remote_subcommand remote(lg2_obj, app); + revparse_subcommand rev(lg2_obj, app); app.require_subcommand(/* min */ 0, /* max */ 1); diff --git a/src/subcommand/revparse_subcommand.cpp b/src/subcommand/revparse_subcommand.cpp new file mode 100644 index 0000000..0d58f15 --- /dev/null +++ b/src/subcommand/revparse_subcommand.cpp @@ -0,0 +1,28 @@ +#include "revparse_subcommand.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->callback([this]() { this->run(); }); +} + +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 + { + std::cout << "revparse only supports --is-bare-repository for now" << std::endl; + } +} diff --git a/src/subcommand/revparse_subcommand.hpp b/src/subcommand/revparse_subcommand.hpp new file mode 100644 index 0000000..baf430a --- /dev/null +++ b/src/subcommand/revparse_subcommand.hpp @@ -0,0 +1,18 @@ +#pragma once + +#include + +#include "../utils/common.hpp" + +class revparse_subcommand +{ +public: + + explicit revparse_subcommand(const libgit2_object&, CLI::App& app); + void run(); + +private: + + bool m_is_bare_repository_flag = false; +}; + diff --git a/src/wrapper/repository_wrapper.cpp b/src/wrapper/repository_wrapper.cpp index 15cdfa8..93aae24 100644 --- a/src/wrapper/repository_wrapper.cpp +++ b/src/wrapper/repository_wrapper.cpp @@ -46,6 +46,11 @@ void repository_wrapper::state_cleanup() throw_if_error(git_repository_state_cleanup(*this)); } +bool repository_wrapper::is_bare() const +{ + return git_repository_is_bare(*this); +} + // References reference_wrapper repository_wrapper::head() const diff --git a/src/wrapper/repository_wrapper.hpp b/src/wrapper/repository_wrapper.hpp index 6b3e55a..5cce074 100644 --- a/src/wrapper/repository_wrapper.hpp +++ b/src/wrapper/repository_wrapper.hpp @@ -33,6 +33,8 @@ class repository_wrapper : public wrapper_base git_repository_state_t state() const; void state_cleanup(); + bool is_bare() const; + // References reference_wrapper head() const; reference_wrapper find_reference(std::string_view ref_name) const; diff --git a/test/test_revparse.py b/test/test_revparse.py new file mode 100644 index 0000000..ea11240 --- /dev/null +++ b/test/test_revparse.py @@ -0,0 +1,16 @@ +import subprocess + +import pytest + +def test_revparse(git2cpp_path, tmp_path, run_in_tmp_path): + # tmp_path exists and is empty. + assert list(tmp_path.iterdir()) == [] + + cmd = [git2cpp_path, 'init', '--bare'] + p = subprocess.run(cmd, cwd = tmp_path) + + cmd2 = [git2cpp_path, 'rev-parse', '--is-bare-repository'] + p2 = subprocess.run(cmd2, capture_output=True, text=True, cwd = tmp_path) + + assert p2.returncode == 0 + assert p2.stdout == 'true\n' From 28cb7a9ab86147e7221740c336143773c1c3f186 Mon Sep 17 00:00:00 2001 From: Johan Mabille Date: Fri, 12 Dec 2025 16:43:34 +0100 Subject: [PATCH 021/116] Fixed status and branch command on newly created repos (#67) * Fixed status and branch command on newly created repos * Fixed typo --- src/subcommand/branch_subcommand.cpp | 2 +- src/subcommand/status_subcommand.cpp | 16 +--------------- src/wrapper/repository_wrapper.cpp | 27 ++++++++++++++++++++++++++- src/wrapper/repository_wrapper.hpp | 6 +++++- test/test_branch.py | 12 ++++++++++++ test/test_status.py | 12 ++++++++++++ 6 files changed, 57 insertions(+), 18 deletions(-) diff --git a/src/subcommand/branch_subcommand.cpp b/src/subcommand/branch_subcommand.cpp index c321fa8..82decf1 100644 --- a/src/subcommand/branch_subcommand.cpp +++ b/src/subcommand/branch_subcommand.cpp @@ -39,7 +39,7 @@ void branch_subcommand::run() void branch_subcommand::run_list(const repository_wrapper& repo) { - auto head_name = repo.head().short_name(); + auto head_name = repo.head_short_name(); git_branch_t type = m_all_flag ? GIT_BRANCH_ALL : (m_remote_flag ? GIT_BRANCH_REMOTE : GIT_BRANCH_LOCAL); auto iter = repo.iterate_branches(type); auto br = iter.next(); diff --git a/src/subcommand/status_subcommand.cpp b/src/subcommand/status_subcommand.cpp index 36aa213..9e98840 100644 --- a/src/subcommand/status_subcommand.cpp +++ b/src/subcommand/status_subcommand.cpp @@ -167,7 +167,7 @@ void status_subcommand::run() 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(); + auto branch_name = repo.head_short_name(); std::set tracked_dir_set{}; std::set untracked_dir_set{}; @@ -276,18 +276,4 @@ void status_subcommand::run() { std::cout << treeclean_message << std::endl; } - - // if (sl.has_ignored_header()) - // { - // stream_colour_fn colour = termcolor::red; - // if (is_long) - // { - // std::cout << ignored_header; - // } - // print_not_tracked(get_entries_to_print(GIT_STATUS_IGNORED, sl, false, of), tracked_dir_set, untracked_dir_set, is_long, colour); - // if (is_long) - // { - // std::cout << std::endl; - // } - // } } diff --git a/src/wrapper/repository_wrapper.cpp b/src/wrapper/repository_wrapper.cpp index 93aae24..d58bb4d 100644 --- a/src/wrapper/repository_wrapper.cpp +++ b/src/wrapper/repository_wrapper.cpp @@ -51,7 +51,12 @@ bool repository_wrapper::is_bare() const return git_repository_is_bare(*this); } -// References +// Head + +bool repository_wrapper::is_head_unborn() const +{ + return git_repository_head_unborn(*this) == 1; +} reference_wrapper repository_wrapper::head() const { @@ -60,6 +65,26 @@ reference_wrapper repository_wrapper::head() const return reference_wrapper(ref); } +std::string repository_wrapper::head_short_name() const +{ + git_reference* ref; + std::string name; + throw_if_error(git_reference_lookup(&ref, *this, "HEAD")); + if (git_reference_type(ref) == GIT_REFERENCE_DIRECT) + { + name = git_reference_shorthand(ref); + } + else + { + name = git_reference_symbolic_target(ref); + name = name.substr(name.find_last_of('/') + 1); + } + git_reference_free(ref); + return name; +} + +// References + reference_wrapper repository_wrapper::find_reference(std::string_view ref_name) const { git_reference* ref; diff --git a/src/wrapper/repository_wrapper.hpp b/src/wrapper/repository_wrapper.hpp index 5cce074..c117e8a 100644 --- a/src/wrapper/repository_wrapper.hpp +++ b/src/wrapper/repository_wrapper.hpp @@ -35,8 +35,12 @@ class repository_wrapper : public wrapper_base bool is_bare() const; - // References + // Head + bool is_head_unborn() const; reference_wrapper head() const; + std::string head_short_name() const; + + // References reference_wrapper find_reference(std::string_view ref_name) const; std::optional find_reference_dwim(std::string_view ref_name) const; diff --git a/test/test_branch.py b/test/test_branch.py index f81bc8c..20c1149 100644 --- a/test/test_branch.py +++ b/test/test_branch.py @@ -38,3 +38,15 @@ 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 + +def test_branch_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'] + p = subprocess.run(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_status.py b/test/test_status.py index ab7288e..6e97e19 100644 --- a/test/test_status.py +++ b/test/test_status.py @@ -76,3 +76,15 @@ def test_status_add_file(xtl_clone, git2cpp_path, tmp_path, short_flag, long_fla elif short_flag in ["-s", "--short"]: assert "A " in p_status.stdout assert "D " in p_status.stdout + +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'] + p = subprocess.run(cmd, cwd = tmp_path) + + status_cmd = [git2cpp_path, 'status'] + p_status = subprocess.run(status_cmd, cwd = tmp_path) + + assert p_status.returncode == 0 From 1cd32b6854990cba817455a83602994d7847adc0 Mon Sep 17 00:00:00 2001 From: Sandrine Pataut Date: Fri, 26 Dec 2025 11:28:38 +0100 Subject: [PATCH 022/116] Add shallow clone (#68) --- src/subcommand/clone_subcommand.cpp | 6 ++++ src/subcommand/clone_subcommand.hpp | 3 ++ src/subcommand/fetch_subcommand.cpp | 23 ++++++++++++ src/subcommand/fetch_subcommand.hpp | 9 +++-- src/subcommand/revparse_subcommand.cpp | 7 +++- src/subcommand/revparse_subcommand.hpp | 2 +- src/wrapper/repository_wrapper.cpp | 9 +++-- src/wrapper/repository_wrapper.hpp | 1 + test/test_clone.py | 20 ++++++++--- test/test_remote.py | 48 ++++++++++++++++++++++++++ test/test_revparse.py | 27 +++++++++++---- 11 files changed, 139 insertions(+), 16 deletions(-) diff --git a/src/subcommand/clone_subcommand.cpp b/src/subcommand/clone_subcommand.cpp index 69b44fa..d660ae1 100644 --- a/src/subcommand/clone_subcommand.cpp +++ b/src/subcommand/clone_subcommand.cpp @@ -11,6 +11,9 @@ 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, "