Merge branch 'kurtmckee/prevent-source-deletio...'
[dotbot.git] / tests / test_link.py
blob389aa7234a5cc6e3db6c90a224bed6326a1d5d1b
1 import os
2 import pathlib
3 import sys
4 from typing import Callable, Optional
6 import pytest
8 from tests.conftest import Dotfiles
11 def test_link_canonicalization(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
12 """Verify links to symlinked destinations are canonical.
14 "Canonical", here, means that dotbot does not create symlinks
15 that point to intermediary symlinks.
16 """
18 dotfiles.write("f", "apple")
19 dotfiles.write_config([{"link": {"~/.f": {"path": "f"}}}])
21 # Point to the config file in a symlinked dotfiles directory.
22 dotfiles_symlink = os.path.join(home, "dotfiles-symlink")
23 os.symlink(dotfiles.directory, dotfiles_symlink)
24 config_file = os.path.join(dotfiles_symlink, os.path.basename(dotfiles.config_filename))
25 run_dotbot("-c", config_file, custom=True)
27 expected = os.path.join(dotfiles.directory, "f")
28 actual = os.readlink(os.path.abspath(os.path.expanduser("~/.f")))
29 if sys.platform == "win32" and actual.startswith("\\\\?\\"):
30 actual = actual[4:]
31 assert expected == actual
34 @pytest.mark.parametrize("dst", ["~/.f", "~/f"])
35 @pytest.mark.parametrize("include_force", [True, False])
36 def test_link_default_source(
37 dst: str,
38 include_force: bool, # noqa: FBT001
39 home: str,
40 dotfiles: Dotfiles,
41 run_dotbot: Callable[..., None],
42 ) -> None:
43 """Verify that default sources are calculated correctly.
45 This test includes verifying files with and without leading periods,
46 as well as verifying handling of None dict values.
47 """
49 _ = home
50 dotfiles.write("f", "apple")
51 config = [
53 "link": {
54 dst: {"force": False} if include_force else None,
58 dotfiles.write_config(config)
59 run_dotbot()
61 with open(os.path.abspath(os.path.expanduser(dst))) as file:
62 assert file.read() == "apple"
65 def test_link_environment_user_expansion_target(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
66 """Verify link expands user in target."""
68 _ = home
69 src = "~/f"
70 target = "~/g"
71 with open(os.path.abspath(os.path.expanduser(src)), "w") as file:
72 file.write("apple")
73 dotfiles.write_config([{"link": {target: src}}])
74 run_dotbot()
76 with open(os.path.abspath(os.path.expanduser(target))) as file:
77 assert file.read() == "apple"
80 def test_link_environment_variable_expansion_source(
81 monkeypatch: pytest.MonkeyPatch, home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]
82 ) -> None:
83 """Verify link expands environment variables in source."""
85 _ = home
86 monkeypatch.setenv("APPLE", "h")
87 target = "~/.i"
88 src = "$APPLE"
89 dotfiles.write("h", "grape")
90 dotfiles.write_config([{"link": {target: src}}])
91 run_dotbot()
93 with open(os.path.abspath(os.path.expanduser(target))) as file:
94 assert file.read() == "grape"
97 def test_link_environment_variable_expansion_source_extended(
98 monkeypatch: pytest.MonkeyPatch, home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]
99 ) -> None:
100 """Verify link expands environment variables in extended config syntax."""
102 _ = home
103 monkeypatch.setenv("APPLE", "h")
104 target = "~/.i"
105 src = "$APPLE"
106 dotfiles.write("h", "grape")
107 dotfiles.write_config([{"link": {target: {"path": src, "relink": True}}}])
108 run_dotbot()
110 with open(os.path.abspath(os.path.expanduser(target))) as file:
111 assert file.read() == "grape"
114 def test_link_environment_variable_expansion_target(
115 monkeypatch: pytest.MonkeyPatch, home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]
116 ) -> None:
117 """Verify link expands environment variables in target.
119 If the variable doesn't exist, the "variable" must not be replaced.
122 monkeypatch.setenv("ORANGE", ".config")
123 monkeypatch.setenv("BANANA", "g")
124 monkeypatch.delenv("PEAR", raising=False)
126 dotfiles.write("f", "apple")
127 dotfiles.write("h", "grape")
129 config = [
131 "link": {
132 "~/${ORANGE}/$BANANA": {
133 "path": "f",
134 "create": True,
136 "~/$PEAR": "h",
140 dotfiles.write_config(config)
141 run_dotbot()
143 with open(os.path.join(home, ".config", "g")) as file:
144 assert file.read() == "apple"
145 with open(os.path.join(home, "$PEAR")) as file:
146 assert file.read() == "grape"
149 def test_link_environment_variable_unset(
150 monkeypatch: pytest.MonkeyPatch, home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]
151 ) -> None:
152 """Verify link leaves unset environment variables."""
154 monkeypatch.delenv("ORANGE", raising=False)
155 dotfiles.write("$ORANGE", "apple")
156 dotfiles.write_config([{"link": {"~/f": "$ORANGE"}}])
157 run_dotbot()
159 with open(os.path.join(home, "f")) as file:
160 assert file.read() == "apple"
163 def test_link_force_leaves_when_nonexistent(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
164 """Verify force doesn't erase sources when targets are nonexistent."""
166 os.mkdir(os.path.join(home, "dir"))
167 open(os.path.join(home, "file"), "a").close()
168 config = [
170 "link": {
171 "~/dir": {"path": "dir", "force": True},
172 "~/file": {"path": "file", "force": True},
176 dotfiles.write_config(config)
177 with pytest.raises(SystemExit):
178 run_dotbot()
180 assert os.path.isdir(os.path.join(home, "dir"))
181 assert os.path.isfile(os.path.join(home, "file"))
184 def test_link_force_overwrite_symlink(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
185 """Verify force overwrites a symlinked directory."""
187 os.mkdir(os.path.join(home, "dir"))
188 dotfiles.write("dir/f")
189 os.symlink(home, os.path.join(home, ".dir"))
191 config = [{"link": {"~/.dir": {"path": "dir", "force": True}}}]
192 dotfiles.write_config(config)
193 run_dotbot()
195 assert os.path.isfile(os.path.join(home, ".dir", "f"))
198 def test_link_glob_1(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
199 """Verify globbing works."""
201 dotfiles.write("bin/a", "apple")
202 dotfiles.write("bin/b", "banana")
203 dotfiles.write("bin/c", "cherry")
204 dotfiles.write_config(
206 {"defaults": {"link": {"glob": True, "create": True}}},
207 {"link": {"~/bin": "bin/*"}},
210 run_dotbot()
212 with open(os.path.join(home, "bin", "a")) as file:
213 assert file.read() == "apple"
214 with open(os.path.join(home, "bin", "b")) as file:
215 assert file.read() == "banana"
216 with open(os.path.join(home, "bin", "c")) as file:
217 assert file.read() == "cherry"
220 def test_link_glob_2(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
221 """Verify globbing works with a trailing slash in the source."""
223 dotfiles.write("bin/a", "apple")
224 dotfiles.write("bin/b", "banana")
225 dotfiles.write("bin/c", "cherry")
226 dotfiles.write_config(
228 {"defaults": {"link": {"glob": True, "create": True}}},
229 {"link": {"~/bin/": "bin/*"}},
232 run_dotbot()
234 with open(os.path.join(home, "bin", "a")) as file:
235 assert file.read() == "apple"
236 with open(os.path.join(home, "bin", "b")) as file:
237 assert file.read() == "banana"
238 with open(os.path.join(home, "bin", "c")) as file:
239 assert file.read() == "cherry"
242 def test_link_glob_3(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
243 """Verify globbing works with hidden ("period-prefixed") files."""
245 dotfiles.write("bin/.a", "dot-apple")
246 dotfiles.write("bin/.b", "dot-banana")
247 dotfiles.write("bin/.c", "dot-cherry")
248 dotfiles.write_config(
250 {"defaults": {"link": {"glob": True, "create": True}}},
251 {"link": {"~/bin/": "bin/.*"}},
254 run_dotbot()
256 with open(os.path.join(home, "bin", ".a")) as file:
257 assert file.read() == "dot-apple"
258 with open(os.path.join(home, "bin", ".b")) as file:
259 assert file.read() == "dot-banana"
260 with open(os.path.join(home, "bin", ".c")) as file:
261 assert file.read() == "dot-cherry"
264 def test_link_glob_4(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
265 """Verify globbing works at the root of the home and dotfiles directories."""
267 dotfiles.write(".a", "dot-apple")
268 dotfiles.write(".b", "dot-banana")
269 dotfiles.write(".c", "dot-cherry")
270 dotfiles.write_config(
273 "link": {
274 "~": {
275 "path": ".*",
276 "glob": True,
282 run_dotbot()
284 with open(os.path.join(home, ".a")) as file:
285 assert file.read() == "dot-apple"
286 with open(os.path.join(home, ".b")) as file:
287 assert file.read() == "dot-banana"
288 with open(os.path.join(home, ".c")) as file:
289 assert file.read() == "dot-cherry"
292 @pytest.mark.parametrize("path", ["foo", "foo/"])
293 def test_link_glob_ignore_no_glob_chars(
294 path: str, home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]
295 ) -> None:
296 """Verify ambiguous link globbing fails."""
298 dotfiles.makedirs("foo")
299 dotfiles.write_config(
302 "link": {
303 "~/foo/": {
304 "path": path,
305 "glob": True,
311 run_dotbot()
312 assert os.path.islink(os.path.join(home, "foo"))
313 assert os.path.exists(os.path.join(home, "foo"))
316 def test_link_glob_exclude_1(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
317 """Verify link globbing with an explicit exclusion."""
319 dotfiles.write("config/foo/a", "apple")
320 dotfiles.write("config/bar/b", "banana")
321 dotfiles.write("config/bar/c", "cherry")
322 dotfiles.write("config/baz/d", "donut")
323 dotfiles.write_config(
326 "defaults": {
327 "link": {
328 "glob": True,
329 "create": True,
334 "link": {
335 "~/.config/": {
336 "path": "config/*",
337 "exclude": ["config/baz"],
343 run_dotbot()
345 assert not os.path.exists(os.path.join(home, ".config", "baz"))
347 assert not os.path.islink(os.path.join(home, ".config"))
348 assert os.path.islink(os.path.join(home, ".config", "foo"))
349 assert os.path.islink(os.path.join(home, ".config", "bar"))
350 with open(os.path.join(home, ".config", "foo", "a")) as file:
351 assert file.read() == "apple"
352 with open(os.path.join(home, ".config", "bar", "b")) as file:
353 assert file.read() == "banana"
354 with open(os.path.join(home, ".config", "bar", "c")) as file:
355 assert file.read() == "cherry"
358 def test_link_glob_exclude_2(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
359 """Verify deep link globbing with a globbed exclusion."""
361 dotfiles.write("config/foo/a", "apple")
362 dotfiles.write("config/bar/b", "banana")
363 dotfiles.write("config/bar/c", "cherry")
364 dotfiles.write("config/baz/d", "donut")
365 dotfiles.write("config/baz/buzz/e", "egg")
366 dotfiles.write_config(
369 "defaults": {
370 "link": {
371 "glob": True,
372 "create": True,
377 "link": {
378 "~/.config/": {
379 "path": "config/*/*",
380 "exclude": ["config/baz/*"],
386 run_dotbot()
388 assert not os.path.exists(os.path.join(home, ".config", "baz"))
390 assert not os.path.islink(os.path.join(home, ".config"))
391 assert not os.path.islink(os.path.join(home, ".config", "foo"))
392 assert not os.path.islink(os.path.join(home, ".config", "bar"))
393 assert os.path.islink(os.path.join(home, ".config", "foo", "a"))
394 with open(os.path.join(home, ".config", "foo", "a")) as file:
395 assert file.read() == "apple"
396 with open(os.path.join(home, ".config", "bar", "b")) as file:
397 assert file.read() == "banana"
398 with open(os.path.join(home, ".config", "bar", "c")) as file:
399 assert file.read() == "cherry"
402 def test_link_glob_exclude_3(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
403 """Verify deep link globbing with an explicit exclusion."""
405 dotfiles.write("config/foo/a", "apple")
406 dotfiles.write("config/bar/b", "banana")
407 dotfiles.write("config/bar/c", "cherry")
408 dotfiles.write("config/baz/d", "donut")
409 dotfiles.write("config/baz/buzz/e", "egg")
410 dotfiles.write("config/baz/bizz/g", "grape")
411 dotfiles.write_config(
414 "defaults": {
415 "link": {
416 "glob": True,
417 "create": True,
422 "link": {
423 "~/.config/": {
424 "path": "config/*/*",
425 "exclude": ["config/baz/buzz"],
431 run_dotbot()
433 assert not os.path.exists(os.path.join(home, ".config", "baz", "buzz"))
435 assert not os.path.islink(os.path.join(home, ".config"))
436 assert not os.path.islink(os.path.join(home, ".config", "foo"))
437 assert not os.path.islink(os.path.join(home, ".config", "bar"))
438 assert not os.path.islink(os.path.join(home, ".config", "baz"))
439 assert os.path.islink(os.path.join(home, ".config", "baz", "bizz"))
440 assert os.path.islink(os.path.join(home, ".config", "foo", "a"))
441 with open(os.path.join(home, ".config", "foo", "a")) as file:
442 assert file.read() == "apple"
443 with open(os.path.join(home, ".config", "bar", "b")) as file:
444 assert file.read() == "banana"
445 with open(os.path.join(home, ".config", "bar", "c")) as file:
446 assert file.read() == "cherry"
447 with open(os.path.join(home, ".config", "baz", "d")) as file:
448 assert file.read() == "donut"
449 with open(os.path.join(home, ".config", "baz", "bizz", "g")) as file:
450 assert file.read() == "grape"
453 def test_link_glob_exclude_4(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
454 """Verify deep link globbing with multiple globbed exclusions."""
456 dotfiles.write("config/foo/a", "apple")
457 dotfiles.write("config/bar/b", "banana")
458 dotfiles.write("config/bar/c", "cherry")
459 dotfiles.write("config/baz/d", "donut")
460 dotfiles.write("config/baz/buzz/e", "egg")
461 dotfiles.write("config/baz/bizz/g", "grape")
462 dotfiles.write("config/fiz/f", "fig")
463 dotfiles.write_config(
466 "defaults": {
467 "link": {
468 "glob": True,
469 "create": True,
474 "link": {
475 "~/.config/": {
476 "path": "config/*/*",
477 "exclude": ["config/baz/*", "config/fiz/*"],
483 run_dotbot()
485 assert not os.path.exists(os.path.join(home, ".config", "baz"))
486 assert not os.path.exists(os.path.join(home, ".config", "fiz"))
488 assert not os.path.islink(os.path.join(home, ".config"))
489 assert not os.path.islink(os.path.join(home, ".config", "foo"))
490 assert not os.path.islink(os.path.join(home, ".config", "bar"))
491 assert os.path.islink(os.path.join(home, ".config", "foo", "a"))
492 with open(os.path.join(home, ".config", "foo", "a")) as file:
493 assert file.read() == "apple"
494 with open(os.path.join(home, ".config", "bar", "b")) as file:
495 assert file.read() == "banana"
496 with open(os.path.join(home, ".config", "bar", "c")) as file:
497 assert file.read() == "cherry"
500 def test_link_glob_multi_star(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
501 """Verify link globbing with deep-nested stars."""
503 dotfiles.write("config/foo/a", "apple")
504 dotfiles.write("config/bar/b", "banana")
505 dotfiles.write("config/bar/c", "cherry")
506 dotfiles.write_config(
508 {"defaults": {"link": {"glob": True, "create": True}}},
509 {"link": {"~/.config/": "config/*/*"}},
512 run_dotbot()
514 assert not os.path.islink(os.path.join(home, ".config"))
515 assert not os.path.islink(os.path.join(home, ".config", "foo"))
516 assert not os.path.islink(os.path.join(home, ".config", "bar"))
517 assert os.path.islink(os.path.join(home, ".config", "foo", "a"))
518 with open(os.path.join(home, ".config", "foo", "a")) as file:
519 assert file.read() == "apple"
520 with open(os.path.join(home, ".config", "bar", "b")) as file:
521 assert file.read() == "banana"
522 with open(os.path.join(home, ".config", "bar", "c")) as file:
523 assert file.read() == "cherry"
526 @pytest.mark.parametrize(
527 ("pattern", "expect_file"),
529 ("conf/*", lambda fruit: fruit),
530 ("conf/.*", lambda fruit: "." + fruit),
531 ("conf/[bc]*", lambda fruit: fruit if fruit[0] in "bc" else None),
532 ("conf/*e", lambda fruit: fruit if fruit[-1] == "e" else None),
533 ("conf/??r*", lambda fruit: fruit if fruit[2] == "r" else None),
536 def test_link_glob_patterns(
537 pattern: str,
538 expect_file: Callable[[str], Optional[str]],
539 home: str,
540 dotfiles: Dotfiles,
541 run_dotbot: Callable[..., None],
542 ) -> None:
543 """Verify link glob pattern matching."""
545 fruits = ["apple", "apricot", "banana", "cherry", "currant", "cantalope"]
546 for fruit in fruits:
547 dotfiles.write("conf/" + fruit, fruit)
548 dotfiles.write("conf/." + fruit, "dot-" + fruit)
549 dotfiles.write_config(
551 {"defaults": {"link": {"glob": True, "create": True}}},
552 {"link": {"~/globtest": pattern}},
555 run_dotbot()
557 for fruit in fruits:
558 expected = expect_file(fruit)
559 if expected is None:
560 assert not os.path.exists(os.path.join(home, "globtest", fruit))
561 assert not os.path.exists(os.path.join(home, "globtest", "." + fruit))
562 elif "." in expected:
563 assert not os.path.islink(os.path.join(home, "globtest", fruit))
564 assert os.path.islink(os.path.join(home, "globtest", "." + fruit))
565 else: # "." not in expected
566 assert os.path.islink(os.path.join(home, "globtest", fruit))
567 assert not os.path.islink(os.path.join(home, "globtest", "." + fruit))
570 def test_link_glob_recursive(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
571 """Verify recursive link globbing and exclusions."""
573 dotfiles.write("config/foo/bar/a", "apple")
574 dotfiles.write("config/foo/bar/b", "banana")
575 dotfiles.write("config/foo/bar/c", "cherry")
576 dotfiles.write_config(
578 {"defaults": {"link": {"glob": True, "create": True}}},
579 {"link": {"~/.config/": {"path": "config/**", "exclude": ["config/**/b"]}}},
582 run_dotbot()
584 assert not os.path.islink(os.path.join(home, ".config"))
585 assert not os.path.islink(os.path.join(home, ".config", "foo"))
586 assert not os.path.islink(os.path.join(home, ".config", "foo", "bar"))
587 assert os.path.islink(os.path.join(home, ".config", "foo", "bar", "a"))
588 assert not os.path.exists(os.path.join(home, ".config", "foo", "bar", "b"))
589 assert os.path.islink(os.path.join(home, ".config", "foo", "bar", "c"))
590 with open(os.path.join(home, ".config", "foo", "bar", "a")) as file:
591 assert file.read() == "apple"
592 with open(os.path.join(home, ".config", "foo", "bar", "c")) as file:
593 assert file.read() == "cherry"
596 def test_link_glob_no_match(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
597 """Verify that a glob with no match doesn't raise an error."""
599 _ = home
600 dotfiles.makedirs("foo")
601 dotfiles.write_config(
603 {"defaults": {"link": {"glob": True, "create": True}}},
604 {"link": {"~/.config/foo": "foo/*"}},
607 run_dotbot()
610 def test_link_glob_single_match(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
611 """Verify linking works even when glob matches exactly one file."""
612 # regression test for https://github.com/anishathalye/dotbot/issues/282
614 dotfiles.write("foo/a", "apple")
615 dotfiles.write_config(
617 {"defaults": {"link": {"glob": True, "create": True}}},
618 {"link": {"~/.config/foo": "foo/*"}},
621 run_dotbot()
623 assert not os.path.islink(os.path.join(home, ".config"))
624 assert not os.path.islink(os.path.join(home, ".config", "foo"))
625 assert os.path.islink(os.path.join(home, ".config", "foo", "a"))
626 with open(os.path.join(home, ".config", "foo", "a")) as file:
627 assert file.read() == "apple"
630 @pytest.mark.skipif(
631 "sys.platform == 'win32'",
632 reason="These if commands won't run on Windows",
634 def test_link_if(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
635 """Verify 'if' directives are checked when linking."""
637 os.mkdir(os.path.join(home, "d"))
638 dotfiles.write("f", "apple")
639 dotfiles.write_config(
642 "link": {
643 "~/.f": {"path": "f", "if": "true"},
644 "~/.g": {"path": "f", "if": "false"},
645 "~/.h": {"path": "f", "if": "[ -d ~/d ]"},
646 "~/.i": {"path": "f", "if": "badcommand"},
651 run_dotbot()
653 assert not os.path.exists(os.path.join(home, ".g"))
654 assert not os.path.exists(os.path.join(home, ".i"))
655 with open(os.path.join(home, ".f")) as file:
656 assert file.read() == "apple"
657 with open(os.path.join(home, ".h")) as file:
658 assert file.read() == "apple"
661 @pytest.mark.skipif(
662 "sys.platform == 'win32'",
663 reason="These if commands won't run on Windows.",
665 def test_link_if_defaults(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
666 """Verify 'if' directive defaults are checked when linking."""
668 os.mkdir(os.path.join(home, "d"))
669 dotfiles.write("f", "apple")
670 dotfiles.write_config(
673 "defaults": {
674 "link": {
675 "if": "false",
680 "link": {
681 "~/.j": {"path": "f", "if": "true"},
682 "~/.k": {"path": "f"}, # default is false
687 run_dotbot()
689 assert not os.path.exists(os.path.join(home, ".k"))
690 with open(os.path.join(home, ".j")) as file:
691 assert file.read() == "apple"
694 @pytest.mark.skipif(
695 "sys.platform != 'win32'",
696 reason="These if commands only run on Windows.",
698 def test_link_if_windows(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
699 """Verify 'if' directives are checked when linking (Windows only)."""
701 os.mkdir(os.path.join(home, "d"))
702 dotfiles.write("f", "apple")
703 dotfiles.write_config(
706 "link": {
707 "~/.f": {"path": "f", "if": 'cmd /c "exit 0"'},
708 "~/.g": {"path": "f", "if": 'cmd /c "exit 1"'},
709 "~/.h": {"path": "f", "if": 'cmd /c "dir %USERPROFILE%\\d'},
710 "~/.i": {"path": "f", "if": 'cmd /c "badcommand"'},
715 run_dotbot()
717 assert not os.path.exists(os.path.join(home, ".g"))
718 assert not os.path.exists(os.path.join(home, ".i"))
719 with open(os.path.join(home, ".f")) as file:
720 assert file.read() == "apple"
721 with open(os.path.join(home, ".h")) as file:
722 assert file.read() == "apple"
725 @pytest.mark.skipif(
726 "sys.platform != 'win32'",
727 reason="These if commands only run on Windows.",
729 def test_link_if_defaults_windows(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
730 """Verify 'if' directive defaults are checked when linking (Windows only)."""
732 os.mkdir(os.path.join(home, "d"))
733 dotfiles.write("f", "apple")
734 dotfiles.write_config(
737 "defaults": {
738 "link": {
739 "if": 'cmd /c "exit 1"',
744 "link": {
745 "~/.j": {"path": "f", "if": 'cmd /c "exit 0"'},
746 "~/.k": {"path": "f"}, # default is false
751 run_dotbot()
753 assert not os.path.exists(os.path.join(home, ".k"))
754 with open(os.path.join(home, ".j")) as file:
755 assert file.read() == "apple"
758 @pytest.mark.parametrize("ignore_missing", [True, False])
759 def test_link_ignore_missing(
760 ignore_missing: bool, # noqa: FBT001
761 home: str,
762 dotfiles: Dotfiles,
763 run_dotbot: Callable[..., None],
764 ) -> None:
765 """Verify link 'ignore_missing' is respected when the target is missing."""
767 dotfiles.write_config(
770 "link": {
771 "~/missing_link": {
772 "path": "missing",
773 "ignore-missing": ignore_missing,
780 if ignore_missing:
781 run_dotbot()
782 assert os.path.islink(os.path.join(home, "missing_link"))
783 else:
784 with pytest.raises(SystemExit):
785 run_dotbot()
788 def test_link_leaves_file(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
789 """Verify relink does not overwrite file."""
791 dotfiles.write("f", "apple")
792 with open(os.path.join(home, ".f"), "w") as file:
793 file.write("grape")
794 dotfiles.write_config([{"link": {"~/.f": "f"}}])
795 with pytest.raises(SystemExit):
796 run_dotbot()
798 with open(os.path.join(home, ".f")) as file:
799 assert file.read() == "grape"
802 @pytest.mark.parametrize("key", ["canonicalize-path", "canonicalize"])
803 def test_link_no_canonicalize(key: str, home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
804 """Verify link canonicalization can be disabled."""
806 dotfiles.write("f", "apple")
807 dotfiles.write_config([{"defaults": {"link": {key: False}}}, {"link": {"~/.f": {"path": "f"}}}])
808 os.symlink(
809 dotfiles.directory,
810 os.path.join(home, "dotfiles-symlink"),
811 target_is_directory=True,
813 run_dotbot(
814 "-c",
815 os.path.join(home, "dotfiles-symlink", os.path.basename(dotfiles.config_filename)),
816 custom=True,
818 assert "dotfiles-symlink" in os.readlink(os.path.join(home, ".f"))
821 def test_link_prefix(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
822 """Verify link prefixes are prepended."""
824 dotfiles.write("conf/a", "apple")
825 dotfiles.write("conf/b", "banana")
826 dotfiles.write("conf/c", "cherry")
827 dotfiles.write_config(
830 "link": {
831 "~/": {
832 "glob": True,
833 "path": "conf/*",
834 "prefix": ".",
840 run_dotbot()
841 with open(os.path.join(home, ".a")) as file:
842 assert file.read() == "apple"
843 with open(os.path.join(home, ".b")) as file:
844 assert file.read() == "banana"
845 with open(os.path.join(home, ".c")) as file:
846 assert file.read() == "cherry"
849 def test_link_relative(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
850 """Test relative linking works."""
852 dotfiles.write("f", "apple")
853 dotfiles.write("d/e", "grape")
854 dotfiles.write_config(
857 "link": {
858 "~/.f": {
859 "path": "f",
861 "~/.frel": {
862 "path": "f",
863 "relative": True,
865 "~/nested/.frel": {
866 "path": "f",
867 "relative": True,
868 "create": True,
870 "~/.d": {
871 "path": "d",
872 "relative": True,
878 run_dotbot()
880 f = os.readlink(os.path.join(home, ".f"))
881 if sys.platform == "win32" and f.startswith("\\\\?\\"):
882 f = f[4:]
883 assert f == os.path.join(dotfiles.directory, "f")
885 frel = os.readlink(os.path.join(home, ".frel"))
886 if sys.platform == "win32" and frel.startswith("\\\\?\\"):
887 frel = frel[4:]
888 assert frel == os.path.normpath("../../dotfiles/f")
890 nested_frel = os.readlink(os.path.join(home, "nested", ".frel"))
891 if sys.platform == "win32" and nested_frel.startswith("\\\\?\\"):
892 nested_frel = nested_frel[4:]
893 assert nested_frel == os.path.normpath("../../../dotfiles/f")
895 d = os.readlink(os.path.join(home, ".d"))
896 if sys.platform == "win32" and d.startswith("\\\\?\\"):
897 d = d[4:]
898 assert d == os.path.normpath("../../dotfiles/d")
900 with open(os.path.join(home, ".f")) as file:
901 assert file.read() == "apple"
902 with open(os.path.join(home, ".frel")) as file:
903 assert file.read() == "apple"
904 with open(os.path.join(home, "nested", ".frel")) as file:
905 assert file.read() == "apple"
906 with open(os.path.join(home, ".d", "e")) as file:
907 assert file.read() == "grape"
910 def test_link_relink_leaves_file(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
911 """Verify relink does not overwrite file."""
913 dotfiles.write("f", "apple")
914 with open(os.path.join(home, ".f"), "w") as file:
915 file.write("grape")
916 dotfiles.write_config([{"link": {"~/.f": {"path": "f", "relink": True}}}])
917 with pytest.raises(SystemExit):
918 run_dotbot()
919 with open(os.path.join(home, ".f")) as file:
920 assert file.read() == "grape"
923 def test_link_relink_overwrite_symlink(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
924 """Verify relink overwrites symlinks."""
926 dotfiles.write("f", "apple")
927 with open(os.path.join(home, "f"), "w") as file:
928 file.write("grape")
929 os.symlink(os.path.join(home, "f"), os.path.join(home, ".f"))
930 dotfiles.write_config([{"link": {"~/.f": {"path": "f", "relink": True}}}])
931 run_dotbot()
932 with open(os.path.join(home, ".f")) as file:
933 assert file.read() == "apple"
936 def test_link_relink_relative_leaves_file(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
937 """Verify relink relative does not incorrectly relink file."""
939 dotfiles.write("f", "apple")
940 with open(os.path.join(home, ".f"), "w") as file:
941 file.write("grape")
942 config = [
944 "link": {
945 "~/.folder/f": {
946 "path": "f",
947 "create": True,
948 "relative": True,
953 dotfiles.write_config(config)
954 run_dotbot()
956 mtime = os.stat(os.path.join(home, ".folder", "f")).st_mtime
958 config[0]["link"]["~/.folder/f"]["relink"] = True
959 dotfiles.write_config(config)
960 run_dotbot()
962 new_mtime = os.stat(os.path.join(home, ".folder", "f")).st_mtime
963 assert mtime == new_mtime
966 def test_source_is_not_overwritten_by_symlink_trickery(
967 capsys: pytest.CaptureFixture[str], home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]
968 ) -> None:
969 dotfiles_path = pathlib.Path(dotfiles.directory)
970 home_path = pathlib.Path(home)
972 # Setup:
973 # * A symlink exists from `~/.ssh` to `ssh` in the dotfiles directory.
974 # * Dotbot is configured to force-recreate a symlink between two files
975 # when, in reality, it's actually the same file when resolved.
976 ssh_config = (dotfiles_path / "ssh/config").absolute()
977 os.mkdir(str(ssh_config.parent))
978 ssh_config.write_text("preserve me!")
979 os.symlink(str(ssh_config.parent), str(home_path / ".ssh"))
980 dotfiles.write_config(
983 "defaults": {
984 "link": {
985 "relink": True,
986 "create": True,
987 "force": True,
992 "link": {
993 # When symlinks are resolved, these are actually the same file.
994 "~/.ssh/config": "ssh/config",
1000 # Execute dotbot.
1001 with pytest.raises(SystemExit):
1002 run_dotbot()
1004 stdout, _ = capsys.readouterr()
1005 assert "appears to be the same file" in stdout
1006 # Verify that the file was not overwritten.
1007 assert ssh_config.read_text() == "preserve me!"
1010 def test_link_defaults_1(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
1011 """Verify that link doesn't overwrite non-dotfiles links by default."""
1013 with open(os.path.join(home, "f"), "w") as file:
1014 file.write("grape")
1015 os.symlink(os.path.join(home, "f"), os.path.join(home, ".f"))
1016 dotfiles.write("f", "apple")
1017 dotfiles.write_config(
1020 "link": {"~/.f": "f"},
1024 with pytest.raises(SystemExit):
1025 run_dotbot()
1027 with open(os.path.join(home, ".f")) as file:
1028 assert file.read() == "grape"
1031 def test_link_defaults_2(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
1032 """Verify that explicit link defaults override the implicit default."""
1034 with open(os.path.join(home, "f"), "w") as file:
1035 file.write("grape")
1036 os.symlink(os.path.join(home, "f"), os.path.join(home, ".f"))
1037 dotfiles.write("f", "apple")
1038 dotfiles.write_config(
1040 {"defaults": {"link": {"relink": True}}},
1041 {"link": {"~/.f": "f"}},
1044 run_dotbot()
1046 with open(os.path.join(home, ".f")) as file:
1047 assert file.read() == "apple"