From 4469b857aaf4389324e56f07caf9aa138a4610d1 Mon Sep 17 00:00:00 2001 From: Kurt McKee Date: Mon, 25 Apr 2022 08:27:39 -0500 Subject: [PATCH] Migrate link-* tests to Python --- tests/test_link.py | 922 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 922 insertions(+) create mode 100644 tests/test_link.py diff --git a/tests/test_link.py b/tests/test_link.py new file mode 100644 index 0000000..e620b6a --- /dev/null +++ b/tests/test_link.py @@ -0,0 +1,922 @@ +import os +import sys + +import pytest + + +# Python 2.7 on Windows does not have an `os.symlink()` function. +# PyPy on Windows raises NotImplementedError when `os.symlink()` is called. +# Older Python 3 versions on Windows require admin rights to create symlinks. +# +# In addition, functions like `os.path.realpath()` on Windows Pythons < 3.8 +# do not resolve symlinks and directory junctions correctly, +# and `shutil.rmtree()` will fail to delete directory junctions. +# +# For these reasons, if the tests are running on Windows with Python < 3.8 +# or with PyPy, the entire link test suite must be skipped. +# +if ( + sys.platform[:5] == "win32" + and (sys.version_info < (3, 8) or "pypy" in sys.version.lower()) +): + reason = "It is impossible to perform link tests on this platform" + pytestmark = pytest.mark.skip(reason=reason) + + +def test_link_canonicalization(home, dotfiles, run_dotbot): + """Verify links to symlinked destinations are canonical. + + "Canonical", here, means that dotbot does not create symlinks + that point to intermediary symlinks. + """ + + dotfiles.write("f", "apple") + dotfiles.write_config([{"link": {"~/.f": {"path": "f"}}}]) + + # Point to the config file in a symlinked dotfiles directory. + dotfiles_symlink = os.path.join(home, "dotfiles-symlink") + os.symlink(dotfiles.directory, dotfiles_symlink) + config_file = os.path.join(dotfiles_symlink, os.path.basename(dotfiles.config_filename)) + run_dotbot("-c", config_file, custom=True) + + expected = os.path.join(dotfiles.directory, "f") + actual = os.readlink(os.path.abspath(os.path.expanduser("~/.f"))) + if sys.platform[:5] == "win32" and actual.startswith("\\\\?\\"): + actual = actual[4:] + assert expected == actual + + +@pytest.mark.parametrize("dst", ("~/.f", "~/f")) +@pytest.mark.parametrize("include_force", (True, False)) +def test_link_default_source(root, home, dst, include_force, dotfiles, run_dotbot): + """Verify that default sources are calculated correctly. + + This test includes verifying files with and without leading periods, + as well as verifying handling of None dict values. + """ + + dotfiles.write("f", "apple") + config = [ + { + "link": { + dst: {"force": False} if include_force else None, + } + } + ] + dotfiles.write_config(config) + run_dotbot() + + with open(os.path.abspath(os.path.expanduser(dst)), "r") as file: + assert file.read() == "apple" + + +def test_link_environment_user_expansion_target(home, dotfiles, run_dotbot): + """Verify link expands user in target.""" + + src = "~/f" + target = "~/g" + with open(os.path.abspath(os.path.expanduser(src)), "w") as file: + file.write("apple") + dotfiles.write_config([{"link": {target: src}}]) + run_dotbot() + + with open(os.path.abspath(os.path.expanduser(target)), "r") as file: + assert file.read() == "apple" + + +def test_link_environment_variable_expansion_source(monkeypatch, root, home, dotfiles, run_dotbot): + """Verify link expands environment variables in source.""" + + monkeypatch.setenv("APPLE", "h") + target = "~/.i" + src = "$APPLE" + dotfiles.write("h", "grape") + dotfiles.write_config([{"link": {target: src}}]) + run_dotbot() + + with open(os.path.abspath(os.path.expanduser(target)), "r") as file: + assert file.read() == "grape" + + +def test_link_environment_variable_expansion_source_extended(monkeypatch, root, home, dotfiles, run_dotbot): + """Verify link expands environment variables in extended config syntax.""" + + monkeypatch.setenv("APPLE", "h") + target = "~/.i" + src = "$APPLE" + dotfiles.write("h", "grape") + dotfiles.write_config([{"link": {target: {"path": src, "relink": True}}}]) + run_dotbot() + + with open(os.path.abspath(os.path.expanduser(target)), "r") as file: + assert file.read() == "grape" + + +def test_link_environment_variable_expansion_target(monkeypatch, root, home, dotfiles, run_dotbot): + """Verify link expands environment variables in target. + + If the variable doesn't exist, the "variable" must not be replaced. + """ + + monkeypatch.setenv("ORANGE", ".config") + monkeypatch.setenv("BANANA", "g") + monkeypatch.delenv("PEAR", raising=False) + + dotfiles.write("f", "apple") + dotfiles.write("h", "grape") + + config = [ + { + "link": { + "~/${ORANGE}/$BANANA": { + "path": "f", + "create": True, + }, + "~/$PEAR": "h", + } + } + ] + dotfiles.write_config(config) + run_dotbot() + + with open(os.path.join(home, ".config", "g"), "r") as file: + assert file.read() == "apple" + with open(os.path.join(home, "$PEAR"), "r") as file: + assert file.read() == "grape" + + +def test_link_environment_variable_unset(monkeypatch, root, home, dotfiles, run_dotbot): + """Verify link leaves unset environment variables.""" + + monkeypatch.delenv("ORANGE", raising=False) + dotfiles.write("$ORANGE", "apple") + dotfiles.write_config([{"link": {"~/f": "$ORANGE"}}]) + run_dotbot() + + with open(os.path.join(home, "f"), "r") as file: + assert file.read() == "apple" + + +def test_link_force_leaves_when_nonexistent(root, home, dotfiles, run_dotbot): + """Verify force doesn't erase sources when targets are nonexistent.""" + + os.mkdir(os.path.join(home, "dir")) + open(os.path.join(home, "file"), "a").close() + config = [ + { + "link": { + "~/dir": {"path": "dir", "force": True}, + "~/file": {"path": "file", "force": True}, + } + } + ] + dotfiles.write_config(config) + with pytest.raises(SystemExit): + run_dotbot() + + assert os.path.isdir(os.path.join(home, "dir")) + assert os.path.isfile(os.path.join(home, "file")) + + +def test_link_force_overwrite_symlink(home, dotfiles, run_dotbot): + """Verify force overwrites a symlinked directory.""" + + os.mkdir(os.path.join(home, "dir")) + dotfiles.write("dir/f") + os.symlink(home, os.path.join(home, ".dir")) + + config = [{"link": {"~/.dir": {"path": "dir", "force": True}}}] + dotfiles.write_config(config) + run_dotbot() + + assert os.path.isfile(os.path.join(home, ".dir", "f")) + + +def test_link_glob_1(home, dotfiles, run_dotbot): + """Verify globbing works.""" + + dotfiles.write("bin/a", "apple") + dotfiles.write("bin/b", "banana") + dotfiles.write("bin/c", "cherry") + dotfiles.write_config([ + {"defaults": {"link": {"glob": True, "create": True}}}, + {"link": {"~/bin": "bin/*"}}, + ]) + run_dotbot() + + with open(os.path.join(home, "bin", "a")) as file: + assert file.read() == "apple" + with open(os.path.join(home, "bin", "b")) as file: + assert file.read() == "banana" + with open(os.path.join(home, "bin", "c")) as file: + assert file.read() == "cherry" + + +def test_link_glob_2(home, dotfiles, run_dotbot): + """Verify globbing works with a trailing slash in the source.""" + + dotfiles.write("bin/a", "apple") + dotfiles.write("bin/b", "banana") + dotfiles.write("bin/c", "cherry") + dotfiles.write_config([ + {"defaults": {"link": {"glob": True, "create": True}}}, + {"link": {"~/bin/": "bin/*"}}, + ]) + run_dotbot() + + with open(os.path.join(home, "bin", "a")) as file: + assert file.read() == "apple" + with open(os.path.join(home, "bin", "b")) as file: + assert file.read() == "banana" + with open(os.path.join(home, "bin", "c")) as file: + assert file.read() == "cherry" + + +def test_link_glob_3(home, dotfiles, run_dotbot): + """Verify globbing works with hidden ("period-prefixed") files.""" + + dotfiles.write("bin/.a", "dot-apple") + dotfiles.write("bin/.b", "dot-banana") + dotfiles.write("bin/.c", "dot-cherry") + dotfiles.write_config([ + {"defaults": {"link": {"glob": True, "create": True}}}, + {"link": {"~/bin/": "bin/.*"}}, + ]) + run_dotbot() + + with open(os.path.join(home, "bin", ".a")) as file: + assert file.read() == "dot-apple" + with open(os.path.join(home, "bin", ".b")) as file: + assert file.read() == "dot-banana" + with open(os.path.join(home, "bin", ".c")) as file: + assert file.read() == "dot-cherry" + + +def test_link_glob_4(home, dotfiles, run_dotbot): + """Verify globbing works at the root of the home and dotfiles directories.""" + + dotfiles.write(".a", "dot-apple") + dotfiles.write(".b", "dot-banana") + dotfiles.write(".c", "dot-cherry") + dotfiles.write_config([{ + "link": { + "~": { + "path": ".*", + "glob": True, + }, + }, + }]) + run_dotbot() + + with open(os.path.join(home, ".a")) as file: + assert file.read() == "dot-apple" + with open(os.path.join(home, ".b")) as file: + assert file.read() == "dot-banana" + with open(os.path.join(home, ".c")) as file: + assert file.read() == "dot-cherry" + + +@pytest.mark.parametrize("path", ("foo", "foo/")) +def test_link_glob_ambiguous_failure(path, home, dotfiles, run_dotbot): + """Verify ambiguous link globbing fails.""" + + dotfiles.makedirs("foo") + dotfiles.write_config([{ + "link": { + "~/foo/": { + "path": path, + "glob": True, + } + } + }]) + with pytest.raises(SystemExit): + run_dotbot() + assert not os.path.exists(os.path.join(home, "foo")) + + +def test_link_glob_ambiguous_success(home, dotfiles, run_dotbot): + """Verify the case where ambiguous link globbing succeeds.""" + + dotfiles.makedirs("foo") + dotfiles.write_config([{ + "link": { + "~/foo": { + "path": "foo", + "glob": True, + } + } + }]) + run_dotbot() + assert os.path.exists(os.path.join(home, "foo")) + + +def test_link_glob_exclude_1(home, dotfiles, run_dotbot): + """Verify link globbing with an explicit exclusion.""" + + dotfiles.write("config/foo/a", "apple") + dotfiles.write("config/bar/b", "banana") + dotfiles.write("config/bar/c", "cherry") + dotfiles.write("config/baz/d", "donut") + dotfiles.write_config([ + { + "defaults": { + "link": { + "glob": True, + "create": True, + }, + }, + }, + { + "link": { + "~/.config/": { + "path": "config/*", + "exclude": ["config/baz"], + }, + }, + }, + ]) + run_dotbot() + + assert not os.path.exists(os.path.join(home, ".config", "baz")) + + assert not os.path.islink(os.path.join(home, ".config")) + assert os.path.islink(os.path.join(home, ".config", "foo")) + assert os.path.islink(os.path.join(home, ".config", "bar")) + with open(os.path.join(home, ".config", "foo", "a")) as file: + assert file.read() == "apple" + with open(os.path.join(home, ".config", "bar", "b")) as file: + assert file.read() == "banana" + with open(os.path.join(home, ".config", "bar", "c")) as file: + assert file.read() == "cherry" + + +def test_link_glob_exclude_2(home, dotfiles, run_dotbot): + """Verify deep link globbing with a globbed exclusion.""" + + dotfiles.write("config/foo/a", "apple") + dotfiles.write("config/bar/b", "banana") + dotfiles.write("config/bar/c", "cherry") + dotfiles.write("config/baz/d", "donut") + dotfiles.write("config/baz/buzz/e", "egg") + dotfiles.write_config([ + { + "defaults": { + "link": { + "glob": True, + "create": True, + }, + }, + }, + { + "link": { + "~/.config/": { + "path": "config/*/*", + "exclude": ["config/baz/*"], + }, + }, + }, + ]) + run_dotbot() + + assert not os.path.exists(os.path.join(home, ".config", "baz")) + + assert not os.path.islink(os.path.join(home, ".config")) + assert not os.path.islink(os.path.join(home, ".config", "foo")) + assert not os.path.islink(os.path.join(home, ".config", "bar")) + assert os.path.islink(os.path.join(home, ".config", "foo", "a")) + with open(os.path.join(home, ".config", "foo", "a")) as file: + assert file.read() == "apple" + with open(os.path.join(home, ".config", "bar", "b")) as file: + assert file.read() == "banana" + with open(os.path.join(home, ".config", "bar", "c")) as file: + assert file.read() == "cherry" + + +def test_link_glob_exclude_3(home, dotfiles, run_dotbot): + """Verify deep link globbing with an explicit exclusion.""" + + dotfiles.write("config/foo/a", "apple") + dotfiles.write("config/bar/b", "banana") + dotfiles.write("config/bar/c", "cherry") + dotfiles.write("config/baz/d", "donut") + dotfiles.write("config/baz/buzz/e", "egg") + dotfiles.write("config/baz/bizz/g", "grape") + dotfiles.write_config([ + { + "defaults": { + "link": { + "glob": True, + "create": True, + }, + }, + }, + { + "link": { + "~/.config/": { + "path": "config/*/*", + "exclude": ["config/baz/buzz"], + }, + }, + }, + ]) + run_dotbot() + + assert not os.path.exists(os.path.join(home, ".config", "baz", "buzz")) + + assert not os.path.islink(os.path.join(home, ".config")) + assert not os.path.islink(os.path.join(home, ".config", "foo")) + assert not os.path.islink(os.path.join(home, ".config", "bar")) + assert not os.path.islink(os.path.join(home, ".config", "baz")) + assert os.path.islink(os.path.join(home, ".config", "baz", "bizz")) + assert os.path.islink(os.path.join(home, ".config", "foo", "a")) + with open(os.path.join(home, ".config", "foo", "a")) as file: + assert file.read() == "apple" + with open(os.path.join(home, ".config", "bar", "b")) as file: + assert file.read() == "banana" + with open(os.path.join(home, ".config", "bar", "c")) as file: + assert file.read() == "cherry" + with open(os.path.join(home, ".config", "baz", "d")) as file: + assert file.read() == "donut" + with open(os.path.join(home, ".config", "baz", "bizz", "g")) as file: + assert file.read() == "grape" + + +def test_link_glob_exclude_4(home, dotfiles, run_dotbot): + """Verify deep link globbing with multiple globbed exclusions.""" + + dotfiles.write("config/foo/a", "apple") + dotfiles.write("config/bar/b", "banana") + dotfiles.write("config/bar/c", "cherry") + dotfiles.write("config/baz/d", "donut") + dotfiles.write("config/baz/buzz/e", "egg") + dotfiles.write("config/baz/bizz/g", "grape") + dotfiles.write("config/fiz/f", "fig") + dotfiles.write_config([ + { + "defaults": { + "link": { + "glob": True, + "create": True, + }, + }, + }, + { + "link": { + "~/.config/": { + "path": "config/*/*", + "exclude": ["config/baz/*", "config/fiz/*"], + }, + }, + }, + ]) + run_dotbot() + + assert not os.path.exists(os.path.join(home, ".config", "baz")) + assert not os.path.exists(os.path.join(home, ".config", "fiz")) + + assert not os.path.islink(os.path.join(home, ".config")) + assert not os.path.islink(os.path.join(home, ".config", "foo")) + assert not os.path.islink(os.path.join(home, ".config", "bar")) + assert os.path.islink(os.path.join(home, ".config", "foo", "a")) + with open(os.path.join(home, ".config", "foo", "a")) as file: + assert file.read() == "apple" + with open(os.path.join(home, ".config", "bar", "b")) as file: + assert file.read() == "banana" + with open(os.path.join(home, ".config", "bar", "c")) as file: + assert file.read() == "cherry" + + +def test_link_glob_multi_star(home, dotfiles, run_dotbot): + """Verify link globbing with deep-nested stars.""" + + dotfiles.write("config/foo/a", "apple") + dotfiles.write("config/bar/b", "banana") + dotfiles.write("config/bar/c", "cherry") + dotfiles.write_config([ + {"defaults": {"link": {"glob": True, "create": True}}}, + {"link": {"~/.config/": "config/*/*"}}, + ]) + run_dotbot() + + assert not os.path.islink(os.path.join(home, ".config")) + assert not os.path.islink(os.path.join(home, ".config", "foo")) + assert not os.path.islink(os.path.join(home, ".config", "bar")) + assert os.path.islink(os.path.join(home, ".config", "foo", "a")) + with open(os.path.join(home, ".config", "foo", "a")) as file: + assert file.read() == "apple" + with open(os.path.join(home, ".config", "bar", "b")) as file: + assert file.read() == "banana" + with open(os.path.join(home, ".config", "bar", "c")) as file: + assert file.read() == "cherry" + + +@pytest.mark.parametrize( + "pattern, expect_file", + ( + ("conf/*", lambda fruit: fruit), + ("conf/.*", lambda fruit: "." + fruit), + ("conf/[bc]*", lambda fruit: fruit if fruit[0] in "bc" else None), + ("conf/*e", lambda fruit: fruit if fruit[-1] == "e" else None), + ("conf/??r*", lambda fruit: fruit if fruit[2] == "r" else None), + ) +) +def test_link_glob_patterns(pattern, expect_file, home, dotfiles, run_dotbot): + """Verify link glob pattern matching.""" + + fruits = ["apple", "apricot", "banana", "cherry", "currant", "cantalope"] + [dotfiles.write("conf/" + fruit, fruit) for fruit in fruits] + [dotfiles.write("conf/." + fruit, "dot-" + fruit) for fruit in fruits] + dotfiles.write_config([ + {"defaults": {"link": {"glob": True, "create": True}}}, + {"link": {"~/globtest": pattern}}, + ]) + run_dotbot() + + for fruit in fruits: + if expect_file(fruit) is None: + assert not os.path.exists(os.path.join(home, "globtest", fruit)) + assert not os.path.exists(os.path.join(home, "globtest", "." + fruit)) + elif "." in expect_file(fruit): + assert not os.path.islink(os.path.join(home, "globtest", fruit)) + assert os.path.islink(os.path.join(home, "globtest", "." + fruit)) + else: # "." not in expect_file(fruit) + assert os.path.islink(os.path.join(home, "globtest", fruit)) + assert not os.path.islink(os.path.join(home, "globtest", "." + fruit)) + + +@pytest.mark.skipif( + "sys.version_info < (3, 5)", + reason="Python 3.5 required for ** globbing", +) +def test_link_glob_recursive(home, dotfiles, run_dotbot): + """Verify recursive link globbing and exclusions.""" + + dotfiles.write("config/foo/bar/a", "apple") + dotfiles.write("config/foo/bar/b", "banana") + dotfiles.write("config/foo/bar/c", "cherry") + dotfiles.write_config([ + {"defaults": {"link": {"glob": True, "create": True}}}, + {"link": {"~/.config/": {"path": "config/**", "exclude": ["config/**/b"]}}}, + ]) + run_dotbot() + + assert not os.path.islink(os.path.join(home, ".config")) + assert not os.path.islink(os.path.join(home, ".config", "foo")) + assert not os.path.islink(os.path.join(home, ".config", "foo", "bar")) + assert os.path.islink(os.path.join(home, ".config", "foo", "bar", "a")) + assert not os.path.exists(os.path.join(home, ".config", "foo", "bar", "b")) + assert os.path.islink(os.path.join(home, ".config", "foo", "bar", "c")) + with open(os.path.join(home, ".config", "foo", "bar", "a")) as file: + assert file.read() == "apple" + with open(os.path.join(home, ".config", "foo", "bar", "c")) as file: + assert file.read() == "cherry" + + +@pytest.mark.skipif( + "sys.platform[:5] == 'win32'", + reason="These if commands won't run on Windows", +) +def test_link_if(home, dotfiles, run_dotbot): + """Verify 'if' directives are checked when linking.""" + + os.mkdir(os.path.join(home, "d")) + dotfiles.write("f", "apple") + dotfiles.write_config([{ + "link": { + "~/.f": {"path": "f", "if": "true"}, + "~/.g": {"path": "f", "if": "false"}, + "~/.h": {"path": "f", "if": "[ -d ~/d ]"}, + "~/.i": {"path": "f", "if": "badcommand"}, + }, + }]) + run_dotbot() + + assert not os.path.exists(os.path.join(home, ".g")) + assert not os.path.exists(os.path.join(home, ".i")) + with open(os.path.join(home, ".f")) as file: + assert file.read() == "apple" + with open(os.path.join(home, ".h")) as file: + assert file.read() == "apple" + + +@pytest.mark.skipif( + "sys.platform[:5] == 'win32'", + reason="These if commands won't run on Windows.", +) +def test_link_if_defaults(home, dotfiles, run_dotbot): + """Verify 'if' directive defaults are checked when linking.""" + + os.mkdir(os.path.join(home, "d")) + dotfiles.write("f", "apple") + dotfiles.write_config([ + { + "defaults": { + "link": { + "if": "false", + }, + }, + }, + { + "link": { + "~/.j": {"path": "f", "if": "true"}, + "~/.k": {"path": "f"}, # default is false + }, + }, + ]) + run_dotbot() + + assert not os.path.exists(os.path.join(home, ".k")) + with open(os.path.join(home, ".j")) as file: + assert file.read() == "apple" + + +@pytest.mark.skipif( + "sys.platform[:5] != 'win32'", + reason="These if commands only run on Windows.", +) +def test_link_if_windows(home, dotfiles, run_dotbot): + """Verify 'if' directives are checked when linking (Windows only).""" + + os.mkdir(os.path.join(home, "d")) + dotfiles.write("f", "apple") + dotfiles.write_config([{ + "link": { + "~/.f": {"path": "f", "if": 'cmd /c "exit 0"'}, + "~/.g": {"path": "f", "if": 'cmd /c "exit 1"'}, + "~/.h": {"path": "f", "if": 'cmd /c "dir %USERPROFILE%\\d'}, + "~/.i": {"path": "f", "if": 'cmd /c "badcommand"'}, + }, + }]) + run_dotbot() + + assert not os.path.exists(os.path.join(home, ".g")) + assert not os.path.exists(os.path.join(home, ".i")) + with open(os.path.join(home, ".f")) as file: + assert file.read() == "apple" + with open(os.path.join(home, ".h")) as file: + assert file.read() == "apple" + + +@pytest.mark.skipif( + "sys.platform[:5] != 'win32'", + reason="These if commands only run on Windows", +) +def test_link_if_defaults_windows(home, dotfiles, run_dotbot): + """Verify 'if' directive defaults are checked when linking (Windows only).""" + + os.mkdir(os.path.join(home, "d")) + dotfiles.write("f", "apple") + dotfiles.write_config([ + { + "defaults": { + "link": { + "if": 'cmd /c "exit 1"', + }, + }, + }, + { + "link": { + "~/.j": {"path": "f", "if": 'cmd /c "exit 0"'}, + "~/.k": {"path": "f"}, # default is false + }, + }, + ]) + run_dotbot() + + assert not os.path.exists(os.path.join(home, ".k")) + with open(os.path.join(home, ".j")) as file: + assert file.read() == "apple" + + +@pytest.mark.parametrize("ignore_missing", (True, False)) +def test_link_ignore_missing(ignore_missing, home, dotfiles, run_dotbot): + """Verify link 'ignore_missing' is respected when the target is missing.""" + + dotfiles.write_config([{ + "link": { + "~/missing_link": { + "path": "missing", + "ignore-missing": ignore_missing, + }, + }, + }]) + + if ignore_missing: + run_dotbot() + assert os.path.islink(os.path.join(home, "missing_link")) + else: + with pytest.raises(SystemExit): + run_dotbot() + + +def test_link_leaves_file(home, dotfiles, run_dotbot): + """Verify relink does not overwrite file.""" + + dotfiles.write("f", "apple") + with open(os.path.join(home, ".f"), "w") as file: + file.write("grape") + dotfiles.write_config([{"link": {"~/.f": "f"}}]) + with pytest.raises(SystemExit): + run_dotbot() + + with open(os.path.join(home, ".f"), "r") as file: + assert file.read() == "grape" + + +@pytest.mark.parametrize("key", ("canonicalize-path", "canonicalize")) +def test_link_no_canonicalize(key, home, dotfiles, run_dotbot): + """Verify link canonicalization can be disabled.""" + + dotfiles.write("f", "apple") + dotfiles.write_config([ + {"defaults": {"link": {key: False}}}, + {"link": {"~/.f": {"path": "f"}}} + ]) + try: + os.symlink( + dotfiles.directory, + os.path.join(home, "dotfiles-symlink"), + target_is_directory=True, + ) + except TypeError: + # Python 2 compatibility: + # target_is_directory is only consistently available after Python 3.3. + os.symlink( + dotfiles.directory, + os.path.join(home, "dotfiles-symlink"), + ) + run_dotbot( + "-c", + os.path.join(home, "dotfiles-symlink", os.path.basename(dotfiles.config_filename)), + custom=True, + ) + assert "dotfiles-symlink" in os.readlink(os.path.join(home, ".f")) + + +def test_link_prefix(home, dotfiles, run_dotbot): + """Verify link prefixes are prepended.""" + + dotfiles.write("conf/a", "apple") + dotfiles.write("conf/b", "banana") + dotfiles.write("conf/c", "cherry") + dotfiles.write_config([{ + "link": { + "~/": { + "glob": True, + "path": "conf/*", + "prefix": ".", + }, + }, + }]) + run_dotbot() + with open(os.path.join(home, ".a")) as file: + assert file.read() == "apple" + with open(os.path.join(home, ".b")) as file: + assert file.read() == "banana" + with open(os.path.join(home, ".c")) as file: + assert file.read() == "cherry" + + +def test_link_relative(home, dotfiles, run_dotbot): + """Test relative linking works.""" + + dotfiles.write("f", "apple") + dotfiles.write("d/e", "grape") + dotfiles.write_config([{ + "link": { + "~/.f": { + "path": "f", + }, + "~/.frel": { + "path": "f", + "relative": True, + }, + "~/nested/.frel": { + "path": "f", + "relative": True, + "create": True, + }, + "~/.d": { + "path": "d", + "relative": True, + }, + }, + }]) + run_dotbot() + + f = os.readlink(os.path.join(home, ".f")) + if sys.platform[:5] == "win32" and f.startswith("\\\\?\\"): + f = f[4:] + assert f == os.path.join(dotfiles.directory, "f") + + frel = os.readlink(os.path.join(home, ".frel")) + if sys.platform[:5] == "win32" and frel.startswith("\\\\?\\"): + frel = frel[4:] + assert frel == os.path.normpath("../../dotfiles/f") + + nested_frel = os.readlink(os.path.join(home, "nested", ".frel")) + if sys.platform[:5] == "win32" and nested_frel.startswith("\\\\?\\"): + nested_frel = nested_frel[4:] + assert nested_frel == os.path.normpath("../../../dotfiles/f") + + d = os.readlink(os.path.join(home, ".d")) + if sys.platform[:5] == "win32" and d.startswith("\\\\?\\"): + d = d[4:] + assert d == os.path.normpath("../../dotfiles/d") + + with open(os.path.join(home, ".f")) as file: + assert file.read() == "apple" + with open(os.path.join(home, ".frel")) as file: + assert file.read() == "apple" + with open(os.path.join(home, "nested", ".frel")) as file: + assert file.read() == "apple" + with open(os.path.join(home, ".d", "e")) as file: + assert file.read() == "grape" + + +def test_link_relink_leaves_file(home, dotfiles, run_dotbot): + """Verify relink does not overwrite file.""" + + dotfiles.write("f", "apple") + with open(os.path.join(home, ".f"), "w") as file: + file.write("grape") + dotfiles.write_config([{"link": {"~/.f": {"path": "f", "relink": True}}}]) + with pytest.raises(SystemExit): + run_dotbot() + with open(os.path.join(home, ".f"), "r") as file: + assert file.read() == "grape" + + +def test_link_relink_overwrite_symlink(home, dotfiles, run_dotbot): + """Verify relink overwrites symlinks.""" + + dotfiles.write("f", "apple") + with open(os.path.join(home, "f"), "w") as file: + file.write("grape") + os.symlink(os.path.join(home, "f"), os.path.join(home, ".f")) + dotfiles.write_config([{"link": {"~/.f": {"path": "f", "relink": True}}}]) + run_dotbot() + with open(os.path.join(home, ".f"), "r") as file: + assert file.read() == "apple" + + +def test_link_relink_relative_leaves_file(home, dotfiles, run_dotbot): + """Verify relink relative does not incorrectly relink file.""" + + dotfiles.write("f", "apple") + with open(os.path.join(home, ".f"), "w") as file: + file.write("grape") + config = [{ + "link": { + "~/.folder/f": { + "path": "f", + "create": True, + "relative": True, + }, + }, + }] + dotfiles.write_config(config) + run_dotbot() + + mtime = os.stat(os.path.join(home, ".folder", "f")).st_mtime + + config[0]["link"]["~/.folder/f"]["relink"] = True + dotfiles.write_config(config) + run_dotbot() + + new_mtime = os.stat(os.path.join(home, ".folder", "f")).st_mtime + assert mtime == new_mtime + + +def test_link_defaults_1(home, dotfiles, run_dotbot): + """Verify that link doesn't overwrite non-dotfiles links by default.""" + + with open(os.path.join(home, "f"), "w") as file: + file.write("grape") + os.symlink(os.path.join(home, "f"), os.path.join(home, ".f")) + dotfiles.write("f", "apple") + dotfiles.write_config([{ + "link": {"~/.f": "f"}, + }]) + with pytest.raises(SystemExit): + run_dotbot() + + with open(os.path.join(home, ".f"), "r") as file: + assert file.read() == "grape" + + +def test_link_defaults_2(home, dotfiles, run_dotbot): + """Verify that explicit link defaults override the implicit default.""" + + with open(os.path.join(home, "f"), "w") as file: + file.write("grape") + os.symlink(os.path.join(home, "f"), os.path.join(home, ".f")) + dotfiles.write("f", "apple") + dotfiles.write_config([ + {"defaults": {"link": {"relink": True}}}, + {"link": {"~/.f": "f"}}, + ]) + run_dotbot() + + with open(os.path.join(home, ".f"), "r") as file: + assert file.read() == "apple"