From 4b5f16d73ae65d97175495132afedb3541e3c657 Mon Sep 17 00:00:00 2001 From: Tim Byrne Date: Sat, 28 Nov 2020 11:23:46 -0600 Subject: [PATCH] Improve support for default branches (#231, #232) Unless a branch is specified, the default remote HEAD is used during clone. Also a local master branch is not created if it is not the remote HEAD. --- test/test_clone.py | 37 +++++++++++++++++++++++++---- test/test_default_remote_branch.py | 27 +++++++++++++++++++++ yadm | 38 ++++++++++++++++++++++-------- yadm.1 | 9 +++---- 4 files changed, 90 insertions(+), 21 deletions(-) create mode 100644 test/test_default_remote_branch.py diff --git a/test/test_clone.py b/test/test_clone.py index b1be814..5e9231a 100644 --- a/test/test_clone.py +++ b/test/test_clone.py @@ -70,6 +70,9 @@ def test_clone( # clone should succeed, and repo should be configured properly assert successful_clone(run, paths, repo_config) + # these clones should have master as HEAD + verify_head(paths, 'master') + # ensure conflicts are handled properly if conflicts: assert 'NOTE' in run.out @@ -162,6 +165,7 @@ def test_clone_bootstrap( assert BOOTSTRAP_MSG not in run.out assert successful_clone(run, paths, repo_config, expected_code) + verify_head(paths, 'master') if not bs_exists: assert BOOTSTRAP_MSG not in run.out @@ -230,6 +234,7 @@ def test_clone_perms( ) assert successful_clone(run, paths, repo_config) + verify_head(paths, 'master') if in_work: # private directories which already exist, should be left as they are, # which in this test is "insecure". @@ -260,7 +265,8 @@ def test_clone_perms( @pytest.mark.usefixtures('remote') -@pytest.mark.parametrize('branch', ['master', 'valid', 'invalid']) +@pytest.mark.parametrize( + 'branch', ['master', 'default', 'valid', 'invalid']) def test_alternate_branch(runner, paths, yadm_cmd, repo_config, branch): """Test cloning a branch other than master""" @@ -269,6 +275,12 @@ def test_alternate_branch(runner, paths, yadm_cmd, repo_config, branch): os.system( f'GIT_DIR="{paths.remote}" git commit ' f'--allow-empty -m "This branch is valid"') + if branch != 'default': + # When branch == 'default', the "default" branch of the remote repo + # will remain "valid" to validate identification the correct default + # branch by inspecting the repo. Otherwise it will be set back to + # "master" + os.system(f'GIT_DIR="{paths.remote}" git checkout master') # clear out the work path paths.work.remove() @@ -278,7 +290,7 @@ def test_alternate_branch(runner, paths, yadm_cmd, repo_config, branch): # run the clone command args = ['clone', '-w', paths.work] - if branch != 'master': + if branch not in ['master', 'default']: args += ['-b', branch] args += [remote_url] run = runner(command=yadm_cmd(*args)) @@ -298,10 +310,12 @@ def test_alternate_branch(runner, paths, yadm_cmd, repo_config, branch): assert run.err == '' assert f'origin\t{remote_url}' in run.out run = runner(command=yadm_cmd('show')) - if branch == 'valid': - assert 'This branch is valid' in run.out - else: + if branch == 'master': assert 'Initial commit' in run.out + verify_head(paths, 'master') + else: + assert 'This branch is valid' in run.out + verify_head(paths, 'valid') def successful_clone(run, paths, repo_config, expected_code=0): @@ -324,3 +338,16 @@ def remote(paths, ds1_repo_copy): # cannot be applied to another fixture. paths.remote.remove() paths.repo.move(paths.remote) + + +def test_no_repo(runner, yadm_cmd, ): + """Test cloning without specifying a repo""" + run = runner(command=yadm_cmd('clone')) + assert run.failure + assert run.err == '' + assert 'ERROR: No repository provided' in run.out + + +def verify_head(paths, branch): + """Assert the local repo has the correct head branch""" + assert paths.repo.join('HEAD').read() == f'ref: refs/heads/{branch}\n' diff --git a/test/test_default_remote_branch.py b/test/test_default_remote_branch.py new file mode 100644 index 0000000..6405417 --- /dev/null +++ b/test/test_default_remote_branch.py @@ -0,0 +1,27 @@ +"""Unit tests: _default_remote_branch()""" +import pytest + + +@pytest.mark.parametrize('condition', ['found', 'missing']) +def test(runner, paths, condition): + """Test _default_remote_branch()""" + test_branch = 'test/branch' + output = f'ref: refs/heads/{test_branch}\\tHEAD\\n' + if condition == 'missing': + output = 'output that is missing ref' + script = f""" + YADM_TEST=1 source {paths.pgm} + function git() {{ + printf '{output}'; + printf 'mock stderr\\n' 1>&2 + }} + _default_remote_branch URL + """ + print(condition) + run = runner(command=['bash'], inp=script) + assert run.success + assert run.err == '' + if condition == 'found': + assert run.out.strip() == test_branch + else: + assert run.out.strip() == 'master' diff --git a/yadm b/yadm index b2b07f6..fbbcb89 100755 --- a/yadm +++ b/yadm @@ -743,13 +743,23 @@ function clean() { } +function _default_remote_branch() { + local ls_remote + ls_remote=$("$GIT_PROGRAM" ls-remote -q --symref "$1" 2>/dev/null) + match="^ref:[[:blank:]]+refs/heads/([^[:blank:]]+)" + if [[ "$ls_remote" =~ $match ]] ; then + echo "${BASH_REMATCH[1]}" + else + echo master + fi +} + function clone() { DO_BOOTSTRAP=1 - local branch - branch="master" + local branch= - clone_args=() + local repo_url= while [[ $# -gt 0 ]] ; do key="$1" case $key in @@ -766,22 +776,29 @@ function clone() { --no-bootstrap) # prevent bootstrap, without prompt DO_BOOTSTRAP=3 ;; - *) # main arguments are kept intact - clone_args+=("$1") + *) # use first found argument as the URL + [ -z "$repo_url" ] && repo_url="$1" ;; esac shift done + [ -z "$repo_url" ] && error_out "No repository provided" + + [ -z "$branch" ] && branch=$(_default_remote_branch "$repo_url") + [ -n "$DEBUG" ] && display_private_perms "initial" + # shellcheck disable=SC2119 # clone will begin with a bare repo - local empty= - init $empty + init + + # configure local HEAD with the correct branch + printf 'ref: refs/heads/%s\n' "$branch" > "${YADM_REPO}/HEAD" # add the specified remote, and configure the repo to track origin/$branch debug "Adding remote to new repo" - "$GIT_PROGRAM" remote add origin "${clone_args[@]}" + "$GIT_PROGRAM" remote add origin "$repo_url" debug "Configuring new repo to track origin/${branch}" "$GIT_PROGRAM" config "branch.${branch}.remote" origin "$GIT_PROGRAM" config "branch.${branch}.merge" "refs/heads/${branch}" @@ -791,13 +808,13 @@ function clone() { "$GIT_PROGRAM" fetch origin || { debug "Removing repo after failed clone" rm -rf "$YADM_REPO" - error_out "Unable to fetch origin ${clone_args[0]}" + error_out "Unable to fetch origin $repo_url" } debug "Verifying '${branch}' is a valid branch to merge" [ -f "${YADM_REPO}/refs/remotes/origin/${branch}" ] || { debug "Removing repo after failed clone" rm -rf "$YADM_REPO" - error_out "Clone failed, 'origin/${branch}' does not exist in ${clone_args[0]}" + error_out "Clone failed, 'origin/${branch}' does not exist in $repo_url" } if [ "$YADM_WORK" = "$HOME" ]; then @@ -1164,6 +1181,7 @@ EOF } +# shellcheck disable=SC2120 function init() { # safety check, don't attempt to init when the repo is already present diff --git a/yadm.1 b/yadm.1 index 3ce2049..ae8b778 100644 --- a/yadm.1 +++ b/yadm.1 @@ -116,12 +116,10 @@ if it exists. .BI clone " url Clone a remote repository for tracking dotfiles. After the contents of the remote repository have been fetched, a "merge" of -.I origin/master -is attempted. +the remote HEAD branch is attempted. If there are conflicting files already present in the .IR work-tree , -this merge will fail and instead a "reset" of -.I origin/master +this merge will fail and instead a "reset" of the remote HEAD branch will be done, followed by a "stash". This "stash" operation will preserve the original data. @@ -154,8 +152,7 @@ but this can be overridden with the .BR -w " option. yadm can be forced to overwrite an existing repository by providing the .BR -f " option. -If you want to use a branch other than -.IR origin/master , +If you want to use a branch other than the remote HEAD branch you can specify it using the .BR -b " option. By default yadm will ask the user if the bootstrap program should be run (if it