[
  {
    "path": ".ci/check-changelog.py",
    "content": "# noqa: INP001\n\n\nimport argparse\nimport re\nimport subprocess\nimport sys\nfrom typing import Set, Tuple\n\n\ndef main() -> None:\n    parser = argparse.ArgumentParser()\n    parser.add_argument(\"--changelog\", type=str, help=\"Path to the changelog file\", required=True)\n    args = parser.parse_args()\n    git_versions = get_git_minor_versions()\n    changelog_versions = get_changelog_versions(args.changelog)\n    missing_versions = git_versions - changelog_versions\n    if missing_versions:\n        print(\"The following versions are missing from the changelog:\")  # noqa: T201\n        for version in sorted(missing_versions, key=version_to_tuple):\n            print(f\"- v{version}\")  # noqa: T201\n        sys.exit(1)\n    changelog_extras = changelog_versions - git_versions\n    if changelog_extras:\n        print(\"The following versions are in the changelog but not in git tags:\")  # noqa: T201\n        for version in sorted(changelog_extras, key=version_to_tuple):\n            print(f\"- v{version}\")  # noqa: T201\n        sys.exit(1)\n    print(\"All versions are present in the changelog.\")  # noqa: T201\n\n\ndef get_git_minor_versions() -> Set[str]:\n    \"\"\"\n    Get all major/minor versions (but not patch versions) from git tags.\n    \"\"\"\n    tags = subprocess.check_output([\"git\", \"tag\", \"--list\"]).decode().splitlines()  # noqa: S607\n    major_minor_re = re.compile(r\"^v(\\d+\\.\\d+)\\.\\d+$\")\n    versions: Set[str] = set()\n    for tag in tags:\n        m = major_minor_re.match(tag)\n        if m:\n            versions.add(m.group(1))\n    return versions\n\n\ndef get_changelog_versions(changelog_path: str) -> Set[str]:\n    \"\"\"\n    Get all versions listed in the changelog.\n    \"\"\"\n    with open(changelog_path) as f:\n        changelog = f.read()\n    version_re = re.compile(r\"^- v(\\d+\\.\\d+)$\", re.MULTILINE)\n    return {m.group(1) for m in version_re.finditer(changelog)}\n\n\ndef version_to_tuple(version: str) -> Tuple[int, ...]:\n    \"\"\"\n    Convert a version string without the \"v\" prefix to a tuple.\n    \"\"\"\n    return tuple(int(i) for i in version.split(\".\"))\n\n\nif __name__ == \"__main__\":\n    main()\n"
  },
  {
    "path": ".editorconfig",
    "content": "root = true\n\n[*]\ncharset = utf-8\nend_of_line = lf\ninsert_final_newline = true\nindent_style = space\ntrim_trailing_whitespace = true\n\n[*.py]\nindent_size = 4\n"
  },
  {
    "path": ".github/dependabot.yaml",
    "content": "version: 2\nupdates:\n  - package-ecosystem: \"github-actions\"\n    directory: \"/\"\n    schedule:\n      interval: \"monthly\"\n    groups:\n      github-actions:\n        patterns:\n          - \"*\"\n"
  },
  {
    "path": ".github/workflows/ci.yml",
    "content": "name: CI\non:\n  push:\n  pull_request:\n  schedule:\n    - cron: \"0 8 * * 6\"\njobs:\n  test:\n    env:\n      PIP_DISABLE_PIP_VERSION_CHECK: 1\n    strategy:\n      fail-fast: false\n      matrix:\n        os: [\"ubuntu-22.04\", \"macos-latest\", \"windows-latest\"]\n        python:\n          - \"3.7\"\n          - \"3.8\"\n          - \"3.9\"\n          - \"3.10\"\n          - \"3.11\"\n          - \"3.12\"\n          - \"3.13\"\n          - \"3.14\"\n          - \"pypy-3.10\"\n        include:\n          - python: \"3.7\"\n            hatch_version: \"1.16.4\"\n        exclude:\n          - os: \"macos-latest\"\n            python: \"3.7\"\n          - os: \"windows-latest\"\n            python: \"3.7\"\n          - os: \"windows-latest\"\n            python: \"pypy-3.10\"\n    runs-on: ${{ matrix.os }}\n    name: \"Test: Python ${{ matrix.python }} on ${{ matrix.os }}\"\n    steps:\n      - uses: actions/checkout@v6\n        with:\n          submodules: recursive\n      - uses: pypa/hatch@install\n        with:\n          version: ${{ matrix.hatch_version || 'latest' }}\n      - run: hatch test -v --cover --include python=$(echo ${{ matrix.python }} | tr -d '-') tests\n      - run: hatch run coverage:xml\n      - uses: codecov/codecov-action@v5\n        with:\n          fail_ci_if_error: true\n          token: ${{ secrets.CODECOV_TOKEN }}\n  typecheck:\n    name: Type check\n    runs-on: ubuntu-24.04\n    steps:\n      - uses: actions/checkout@v6\n      - uses: pypa/hatch@install\n      - run: hatch run types:check\n  fmt:\n    name: Format and lint\n    runs-on: ubuntu-24.04\n    steps:\n      - uses: actions/checkout@v6\n      - uses: pypa/hatch@install\n      - run: hatch fmt --check\n  changelog:\n    name: Check changelog\n    runs-on: ubuntu-24.04\n    steps:\n      - uses: actions/checkout@v6\n        with:\n          fetch-depth: 0\n          fetch-tags: true\n      - uses: pypa/hatch@install\n      - run: hatch run python .ci/check-changelog.py --changelog CHANGELOG.md\n"
  },
  {
    "path": ".github/workflows/release.yml",
    "content": "name: Release\n\non:\n  push:\n    tags:\n      - \"v[0-9]+.[0-9]+.[0-9]+\"\n\njobs:\n  test:\n    env:\n      PIP_DISABLE_PIP_VERSION_CHECK: 1\n    strategy:\n      fail-fast: false\n      matrix:\n        include:\n          - os: ubuntu-latest\n            platform: linux\n            arch: x64\n          - os: ubuntu-24.04-arm\n            platform: linux\n            arch: arm64\n          - os: macos-13\n            platform: macos\n            arch: x64\n          - os: macos-latest\n            platform: macos\n            arch: arm64\n          - os: windows-latest\n            platform: windows\n            arch: x64\n          - os: windows-11-arm\n            platform: windows\n            arch: arm64\n    runs-on: ${{ matrix.os }}\n    name: \"Test: ${{ matrix.platform }}-${{ matrix.arch }}\"\n    steps:\n      - uses: actions/checkout@v6\n        with:\n          submodules: recursive\n      - uses: pypa/hatch@install\n      - run: hatch test\n\n  build-binaries:\n    needs: test\n    strategy:\n      matrix:\n        include:\n          - os: ubuntu-latest\n            platform: linux\n            arch: x64\n          - os: ubuntu-24.04-arm\n            platform: linux\n            arch: arm64\n          - os: macos-13\n            platform: macos\n            arch: x64\n          - os: macos-latest\n            platform: macos\n            arch: arm64\n          - os: windows-latest\n            platform: windows\n            arch: x64\n          - os: windows-11-arm\n            platform: windows\n            arch: arm64\n    runs-on: ${{ matrix.os }}\n    name: Build binary for ${{ matrix.platform }}-${{ matrix.arch }}\n    steps:\n      - uses: actions/checkout@v6\n        with:\n          submodules: recursive\n      - uses: actions/setup-python@v6\n        with:\n          python-version: \"3.11\"\n      - name: Install dependencies\n        run: |\n          python -m pip install --upgrade pip\n          pip install pyinstaller\n          pip install .\n      - name: Build binary\n        run: |\n          pyinstaller --onefile --name dotbot src/dotbot/cli.py\n      - name: Package binary\n        shell: bash\n        run: |\n          if [[ \"${{ matrix.platform }}\" == \"windows\" ]]; then\n            cd dist && powershell Compress-Archive -Path dotbot.exe -DestinationPath dotbot-${{ matrix.platform }}-${{ matrix.arch }}.zip\n          else\n            cd dist && tar -czf dotbot-${{ matrix.platform }}-${{ matrix.arch }}.tar.gz dotbot\n          fi\n      - name: Upload binary\n        uses: actions/upload-artifact@v5\n        with:\n          name: dotbot-${{ matrix.platform }}-${{ matrix.arch }}\n          path: dist/dotbot-${{ matrix.platform }}-${{ matrix.arch }}.*\n\n  github-release:\n    needs: build-binaries\n    runs-on: ubuntu-latest\n    name: Create GitHub Release\n    permissions:\n      contents: write\n    steps:\n      - uses: actions/checkout@v6\n      - name: Download all artifacts\n        uses: actions/download-artifact@v6\n        with:\n          path: artifacts\n      - name: Create Release\n        uses: softprops/action-gh-release@v2\n        with:\n          files: artifacts/*/dotbot-*\n          generate_release_notes: true\n"
  },
  {
    "path": ".gitignore",
    "content": "*.pyc\ndist/\n.coverage*\n"
  },
  {
    "path": ".gitmodules",
    "content": "[submodule \"lib/pyyaml\"]\n\tpath = lib/pyyaml\n\turl = https://github.com/yaml/pyyaml\n\tignore = dirty\n"
  },
  {
    "path": "CHANGELOG.md",
    "content": "Note: this changelog only lists feature additions, not bugfixes. For details on those, see the Git history.\n\n- v1.24\n    - Add `backup:` option for `link`\n- v1.23\n    - Switch default output to only show actions taken\n    - Add support for `--dry-run`\n    - Add ability to specify plugins in config file\n- v1.22\n    - Add support for creating hardlinks in `link`\n    - Add ability to pass multiple config files\n- v1.21\n    - Drop support for Python 3.6: the minimum version supported is now Python 3.7\n- v1.20\n    - Drop support for Python 2 and old versions of Python 3: the minimum version supported is now Python 3.6\n- v1.19\n    - Add `mode:` option for `create`\n    - Add `exclude:` option for `link`\n- v1.18\n    - Add `--only` and `--except` flags\n    - Add support to run with `python -m dotbot`\n    - Add `--force-color` option\n- v1.17\n    - Add `canonicalize-path:` option for `link`\n- v1.16\n    - Add `create` plugin\n- v1.15\n    - Add `quiet:` option for `shell`\n- v1.14\n    - Add `if:` option for `link`\n- v1.13\n    - Add `--no-color` flag\n- v1.12\n    - Add globbing support to `link`\n- v1.11\n    - Add force option to `clean` to remove all broken symlinks\n- v1.10\n    - Update `link` to support shorthand syntax for links\n- v1.9\n    - Add support for default options for commands\n- v1.8\n    - Update `link` to be able to create relative links\n- v1.7\n    - Add support for plugins\n- v1.6\n    - Update `link` to expand environment variables in paths\n- v1.5\n    - Update `link` to be able to automatically overwrite broken symlinks\n- v1.4\n    - Update `shell` to allow for selectively enabling/disabling stdin, stdout, and stderr\n- v1.3\n    - Add support for YAML format configs\n- v1.2\n    - Update `link` to be able to force create links (deleting things that were previously there)\n    - Update `link` to be able to create parent directories\n- v1.1\n    - Update `clean` to remove old broken symlinks\n- v1.0\n    - Initial commit\n"
  },
  {
    "path": "CONTRIBUTING.md",
    "content": "# Contributing\n\nAll kinds of contributions to Dotbot are greatly appreciated. For someone unfamiliar with the code base, the most efficient way to contribute is usually to submit a [feature request](#feature-requests) or [bug report](#bug-reports). If you want to dive into the source code, you can submit a [patch](#patches) as well, either working on your own ideas or [existing issues][issues].\n\n## Feature Requests\n\nDo you have an idea for an awesome new feature for Dotbot? Please [submit a feature request][issue]. It's great to hear about new ideas.\n\nIf you are inclined to do so, you're welcome to [fork][fork] Dotbot, work on implementing the feature yourself, and submit a patch. In this case, it's *highly recommended* that you first [open an issue][issue] describing your enhancement to get early feedback on the new feature that you are implementing. This will help avoid wasted efforts and ensure that your work is incorporated into the code base.\n\n## Bug Reports\n\nDid something go wrong with Dotbot? Sorry about that! Bug reports are greatly appreciated!\n\nWhen you [submit a bug report][issue], please include relevant information such as Dotbot version, operating system, configuration file, error messages, and steps to reproduce the bug. The more details you can include, the easier it is to find and fix the bug.\n\n## Patches\n\nWant to hack on Dotbot? Awesome!\n\nIf there are [open issues][issues], you're more than welcome to work on those - this is probably the best way to contribute to Dotbot. If you have your own ideas, that's great too! In that case, before working on substantial changes to the code base, it is *highly recommended* that you first [open an issue][issue] describing what you intend to work on.\n\n**Patches are generally submitted as pull requests.** Patches are also [accepted over email][email].\n\nAny changes to the code base should follow the style and coding conventions used in the rest of the project. The version history should be clean, and commit messages should be descriptive and [properly formatted][commit-messages]. It's recommended that you add unit tests to demonstrate that the bug is fixed (or that the feature works).\n\nSee the [Dotbot development guide][development] to learn how to run the tests, type checking, and more.\n\n---\n\nIf you have any questions about anything, feel free to [ask][email]!\n\n[issue]: https://github.com/anishathalye/dotbot/issues/new\n[issues]: https://github.com/anishathalye/dotbot/issues\n[fork]: https://github.com/anishathalye/dotbot/fork\n[email]: mailto:me@anishathalye.com\n[commit-messages]: http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html\n[development]: DEVELOPMENT.md\n"
  },
  {
    "path": "DEVELOPMENT.md",
    "content": "# Development\n\nDotbot uses the [Hatch] project manager ([installation instructions][hatch-install]).\n\nHatch automatically manages dependencies and runs testing, type checking, and other operations in isolated [environments][hatch-environments].\n\n[Hatch]: https://hatch.pypa.io/\n[hatch-install]: https://hatch.pypa.io/latest/install/\n[hatch-environments]: https://hatch.pypa.io/latest/environment/\n\n## Testing\n\nYou can run the tests on your local machine with:\n\n```bash\nhatch test\n```\n\nThe [`test` command][hatch-test] supports options such as `-c` for measuring test coverage, `-a` for testing with a matrix of Python versions, and appending an argument like `tests/test_shell.py::test_shell_can_override_defaults` for running a single test.\n\n[hatch-test]: https://hatch.pypa.io/latest/tutorials/testing/overview/\n\n### Isolation\n\nDotbot executes shell commands and interacts with the filesystem, and the tests exercise this functionality. The tests try to [insulate][dotbot-conftest] themselves from the machine, but if you prefer to run tests in an isolated container using Docker, you can do so with the following:\n\n```bash\ndocker run -it --rm -v \"${PWD}:/dotbot\" -w /dotbot python:3.13-bookworm /bin/bash\n```\n\nAfter spawning the container, install Hatch with `pip install hatch`, and then run the tests as described above.\n\n[dotbot-conftest]: tests/conftest.py\n\n## Type checking\n\nYou can run the [mypy static type checker][mypy] with:\n\n```bash\nhatch run types:check\n```\n\n[mypy]: https://mypy-lang.org/\n\n## Formatting and linting\n\nYou can run the [Ruff][ruff] formatter and linter with:\n\n```bash\nhatch fmt\n```\n\nThis will automatically make [safe fixes][fix-safety] to your code. If you want to only check your files without making modifications, run `hatch fmt --check`.\n\n[ruff]: https://github.com/astral-sh/ruff\n[fix-safety]: https://docs.astral.sh/ruff/linter/#fix-safety\n\n## Packaging\n\nYou can use [`hatch build`][hatch-build] to create build artifacts, a [source distribution (\"sdist\")][sdist] and a [built distribution (\"wheel\")][bdist].\n\nYou can use [`hatch publish`][hatch-publish] to publish build artifacts to [PyPI][pypi].\n\n[hatch-build]: https://hatch.pypa.io/latest/build/\n[sdist]: https://packaging.python.org/en/latest/glossary/#term-Source-Distribution-or-sdist\n[bdist]: https://packaging.python.org/en/latest/glossary/#term-Built-Distribution\n[hatch-publish]: https://hatch.pypa.io/latest/publish/\n[pypi]: https://pypi.org/\n\n## Continuous integration\n\nTesting, type checking, and formatting/linting is [checked in CI][ci].\n\n[ci]: .github/workflows/ci.yml\n"
  },
  {
    "path": "LICENSE.md",
    "content": "The MIT License (MIT)\n=====================\n\n**Copyright (c) Anish Athalye (me@anishathalye.com)**\n\nPermission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the \"Software\"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\n"
  },
  {
    "path": "README.md",
    "content": "# Dotbot [![Build Status](https://github.com/anishathalye/dotbot/actions/workflows/ci.yml/badge.svg)](https://github.com/anishathalye/dotbot/actions/workflows/ci.yml) [![Coverage](https://codecov.io/gh/anishathalye/dotbot/branch/master/graph/badge.svg)](https://app.codecov.io/gh/anishathalye/dotbot) [![PyPI](https://img.shields.io/pypi/v/dotbot.svg)](https://pypi.org/pypi/dotbot/) [![PyPI - Python version](https://img.shields.io/pypi/pyversions/dotbot.svg)](https://pypi.org/pypi/dotbot/)\n\nDotbot makes installing your dotfiles as easy as `git clone $url && cd dotfiles && ./install`, even on a freshly installed system!\n\n- [Rationale](#rationale)\n- [Getting Started](#getting-started)\n- [Installing Your Dotfiles](#installing-your-dotfiles)\n- [Configuration](#configuration)\n- [Directives](#directives) ([Link](#link), [Create](#create), [Shell](#shell), [Clean](#clean), [Defaults](#defaults))\n- [Plugins](#plugins)\n- [Command-line Arguments](#command-line-arguments)\n- [Wiki][wiki]\n\n---\n\n## Rationale\n\nDotbot is a tool that bootstraps your dotfiles (it's a [Dot]files [bo]o[t]strapper, get it?). It does *less* than you think, because version control systems do more than you think.\n\nDotbot is designed to be lightweight and self-contained, with no external dependencies and no installation required. Dotbot can also be a drop-in replacement for any other tool you were using to manage your dotfiles, and Dotbot is VCS-agnostic &mdash; it doesn't make any attempt to manage your dotfiles.\n\nDotbot has many [plugins] that extend its functionality, such as:\n\n- Secrets management: [dotbot-age](https://github.com/fcatuhe/dotbot-age), [dotbot-gitcrypt](https://gitlab.com/gnfzdz/dotbot-gitcrypt), &mldr;\n- Package management: [dotbot-brew](https://github.com/d12frosted/dotbot-brew), [dotbot-apt](https://github.com/bryant1410/dotbot-apt), [dotbot-yum](https://gitlab.com/flyingchipmunk/dotbot-yum), &mldr;\n- OS and application configuration: [crontab-dotbot](https://github.com/fundor333/crontab-dotbot), [dotbot-firefox](https://github.com/kurtmckee/dotbot-firefox), &mldr;\n- &mldr; and [more][plugins]!\n\nSee [this blog post](https://www.anishathalye.com/2014/08/03/managing-your-dotfiles/) or more resources on the [tutorials page](https://github.com/anishathalye/dotbot/wiki/Tutorials) for more detailed explanations of how to organize your dotfiles.\n\n## Getting started\n\n### Starting fresh?\n\nGreat! You can automate the creation of your dotfiles by using the user-contributed [init-dotfiles][init-dotfiles] script. If you'd rather use a template repository, check out [dotfiles_template][dotfiles-template]. Or, if you're just looking for [some inspiration][inspiration], we've got you covered.\n\n### Integrate with existing dotfiles\n\nThe following will help you get set up using Dotbot in just a few steps.\n\nYou can create an empty configuration file with:\n\n```bash\ntouch install.conf.yaml\n```\n\nIf you're using **Git**, you can add Dotbot as a submodule:\n\n```bash\ncd ~/.dotfiles # replace with the path to your dotfiles\ngit init # initialize repository if needed\ngit submodule add https://github.com/anishathalye/dotbot\ngit config -f .gitmodules submodule.dotbot.ignore dirty # ignore dirty commits in the submodule\ncp dotbot/tools/git-submodule/install .\n```\n\nIf you're using **Mercurial**, you can add Dotbot as a subrepo:\n\n```bash\ncd ~/.dotfiles # replace with the path to your dotfiles\nhg init # initialize repository if needed\necho \"dotbot = [git]https://github.com/anishathalye/dotbot\" > .hgsub\nhg add .hgsub\ngit clone https://github.com/anishathalye/dotbot\ncp dotbot/tools/hg-subrepo/install .\n```\n\nIf you are using PowerShell instead of a POSIX shell, you can use the provided `install.ps1` script instead of `install`. On Windows, Dotbot only supports Python 3.8+, and it requires that your account is [allowed to create symbolic links][windows-symlinks].\n\nTo get started, you just need to fill in the `install.conf.yaml` and Dotbot will take care of the rest. To help you get started we have [an example](#full-example) config file as well as [configuration documentation](#configuration) for the accepted parameters.\n\nNote: The `install` script is merely a shim that checks out the appropriate version of Dotbot and calls the full Dotbot installer. By default, the script assumes that the configuration is located in `install.conf.yaml` the Dotbot submodule is located in `dotbot`. You can change either of these parameters by editing the variables in the `install` script appropriately.\n\nSetting up Dotbot as a submodule or subrepo locks it on the current version. You can upgrade Dotbot at any point. If using a submodule, run `git submodule update --remote dotbot`, substituting `dotbot` with the path to the Dotbot submodule; be sure to commit your changes before running `./install`, otherwise the old version of Dotbot will be checked out by the install script. If using a subrepo, run `git fetch && git checkout origin/master` in the Dotbot directory.\n\n#### Installation as a command-line program\n\nIf you prefer, instead of bundling Dotbot as a submodule with your dotfiles, you can install Dotbot from [PyPI] as a standalone command-line program. Use the tool of your choice, such as `pip` or [`uv`][uv]:\n\n```bash\nuv tool install dotbot\n```\n\nSome systems include Dotbot in their native package manager, such as [Homebrew][homebrew-dotbot] and [Arch Linux][arch-dotbot], so for example, you can also install it with `brew install dotbot`.\n\nWith Dotbot installed as a command-line program on your system, you can invoke Dotbot with `dotbot -c <path to configuration file>`.\n\n### Full example\n\nHere's an example of a complete configuration.\n\nThe conventional name for the configuration file is `install.conf.yaml`.\n\n```yaml\n- defaults:\n    link:\n      relink: true\n\n- clean: ['~']\n\n- link:\n    ~/.tmux.conf: tmux.conf\n    ~/.vim: vim\n    ~/.vimrc: vimrc\n\n- create:\n    - ~/downloads\n    - ~/.vim/undo-history\n\n- shell:\n  - [git submodule update --init --recursive, Installing submodules]\n```\n\nThe configuration file is typically written in YAML, but it can also be written in JSON (which is a [subset of YAML][json2yaml]). JSON configuration files are conventionally named `install.conf.json`.\n\n## Installing Your Dotfiles\n\nTo install your dotfiles on a new machine or after updates:\n\n```bash\ngit clone <your-dotfiles-repo-url> ~/.dotfiles\ncd ~/.dotfiles\n./install\n```\n\nTo update an existing installation:\n\n```bash\ncd ~/.dotfiles\ngit pull\n./install\n```\n\n## Configuration\n\nDotbot uses YAML or JSON-formatted configuration files to let you specify how to set up your dotfiles. Currently, Dotbot knows how to [link](#link) files and folders, [create](#create) folders, execute [shell](#shell) commands, and [clean](#clean) directories of broken symbolic links. Dotbot also supports user [plugins](#plugins) for custom commands.\n\n**Ideally, bootstrap configurations should be idempotent. That is, the installer should be able to be run multiple times without causing any problems.** This makes a lot of things easier to do (in particular, syncing updates between machines becomes really easy).\n\nDotbot configuration files are arrays of tasks, where each task is a dictionary that contains a command name mapping to data for that command. Tasks are run in the order in which they are specified. Commands within a task do not have a defined ordering.\n\nWhen writing nested constructs, keep in mind that YAML is whitespace-sensitive. Following the formatting used in the examples is a good idea. If a YAML configuration file is not behaving as you expect, try inspecting the [equivalent JSON][json2yaml] and check that it is correct.\n\n## Directives\n\nMost Dotbot commands support both a simplified and extended syntax, and they can also be configured via setting [defaults](#defaults).\n\n### Link\n\nLink commands create symbolic links at specified locations that point to files in your dotfiles repository. This allows you to keep your configuration files in version control while having them appear where applications expect to find them. Symlinks are created by default, but hardlinks are also supported. If desired, items can be specified to be forcibly linked, overwriting existing files if necessary. Environment variables in paths are automatically expanded.\n\n#### Format\n\nLink commands are specified as a dictionary mapping link names to targets. The link name (key) is where the symbolic link will be created, and the target (value) is the file in your dotfiles directory that the link will point to. Targets are specified relative to the base directory (that is specified when running the installer). If linking directories, *do not* include a trailing slash.\n\nLink commands support an optional extended configuration. In this type of configuration, instead of specifying targets directly, link names are mapped to extended configuration dictionaries.\n\n| Parameter | Explanation |\n| --- | --- |\n| `path` | The target for the link (file in dotfiles directory), the same as in the shortcut syntax (default: null, automatic (see below)) |\n| `type` | The type of link to create. If specified, must be either `symlink` or `hardlink`. (default: `symlink`) |\n| `create` | When true, create parent directories to the link as needed. (default: false) |\n| `relink` | Removes the old link if it's a symlink (default: false) |\n| `force` | Force removes the old link, file or folder, and forces a new link (default: false) |\n| `backup` | Backup existing files/directories if they exist, creating a backup with suffix `.dotbot-backup.{timestamp}` (default: false) |\n| `relative` | When creating a symlink, use a relative path to the target. (default: false, absolute links) |\n| `canonicalize` | Resolve any symbolic links encountered in the target to symlink to the canonical path (default: true, real paths) |\n| `if` | Execute this in your `$SHELL` and only link if it is successful. |\n| `ignore-missing` | Do not fail if the target is missing and create the link anyway (default: false) |\n| `glob` | Treat `path` as a glob pattern, expanding patterns referenced below, linking all *files* matched. (default: false) |\n| `exclude` | Array of glob patterns to remove from glob matches. Uses same syntax as `path`. Ignored if `glob` is `false`. (default: empty, keep all matches) |\n| `prefix` | Prepend prefix prefix to basename of each file when linked, when `glob` is `true`. (default: '') |\n\nWhen `glob: true`, Dotbot uses [glob.glob](https://docs.python.org/3/library/glob.html#glob.glob) to resolve glob paths, expanding Unix shell-style wildcards, which are **not** the same as regular expressions; Only the following are expanded:\n\n| Pattern  | Meaning                            |\n|:---------|:-----------------------------------|\n| `*`      | matches anything                   |\n| `**`     | matches any **file**, recursively  |\n| `?`      | matches any single character       |\n| `[seq]`  | matches any character in `seq`     |\n| `[!seq]` | matches any character not in `seq` |\n\nHowever, due to the design of `glob.glob`, using a glob pattern such as `config/*`, will **not** match items that begin with `.`. To specifically capture items that being with `.`, you will need to include the `.` in the pattern, like this: `config/.*`.\n\nWhen using glob with the `exclude:` option, the paths in the exclude paths should be relative to the base directory, same as the glob pattern itself. For example, if a glob pattern `vim/*` matches directories `vim/autoload`, `vim/ftdetect`, `vim/ftplugin`, and `vim/spell`, and you want to ignore the spell directory, then you should use `exclude: [\"vim/spell\"]` (not just `\"spell\"`).\n\n#### Example\n\n```yaml\n- link:\n    ~/.config/terminator:\n      create: true\n      path: config/terminator\n    ~/.vim: vim\n    ~/.vimrc:\n      relink: true\n      path: vimrc\n    ~/.zshrc:\n      force: true\n      path: zshrc\n    ~/.hammerspoon:\n      if: '[ `uname` = Darwin ]'\n      path: hammerspoon\n    ~/.config/:\n      glob: true\n      path: dotconf/config/**\n    ~/:\n      glob: true\n      path: dotconf/*\n      prefix: '.'\n```\n\nIf the target location is omitted or set to `null`, Dotbot will use the basename of the link name, with a leading `.` stripped if present. This makes the following two config files equivalent.\n\nExplicit targets:\n\n```yaml\n- link:\n    ~/bin/ack: ack\n    ~/.vim: vim\n    ~/.vimrc:\n      relink: true\n      path: vimrc\n    ~/.zshrc:\n      force: true\n      path: zshrc\n    ~/.config/:\n      glob: true\n      path: config/*\n      relink: true\n      exclude: [ config/Code ]\n    ~/.config/Code/User/:\n      create: true\n      glob: true\n      path: config/Code/User/*\n      relink: true\n```\n\nImplicit targets:\n\n```yaml\n- link:\n    ~/bin/ack:\n    ~/.vim:\n    ~/.vimrc:\n      relink: true\n    ~/.zshrc:\n      force: true\n    ~/.config/:\n      glob: true\n      path: config/*\n      relink: true\n      exclude: [ config/Code ]\n    ~/.config/Code/User/:\n      create: true\n      glob: true\n      path: config/Code/User/*\n      relink: true\n```\n\n### Create\n\nCreate commands specify empty directories to be created.  This can be useful for scaffolding out folders or parent folder structure required for various apps, plugins, shell commands, etc.\n\n#### Format\n\nCreate commands are specified as an array of directories to be created. If you want to use the optional extended configuration, create commands are specified as dictionaries. For convenience, it's permissible to leave the options blank (null) in the dictionary syntax.\n\n| Parameter | Explanation |\n| --- | --- |\n| `mode` | The file mode to use for creating the leaf directory (default: 0777) |\n\nThe `mode` parameter is treated in the same way as in Python's [os.mkdir](https://docs.python.org/3/library/os.html#mkdir-modebits). Its behavior is platform-dependent. On Unix systems, the current umask value is first masked out.\n\n#### Example\n\n```yaml\n- create:\n    - ~/downloads\n    - ~/.vim/undo-history\n- create:\n    ~/.ssh:\n      mode: 0700\n    ~/projects:\n```\n\n### Shell\n\nShell commands specify shell commands to be run. Shell commands are run in the base directory (that is specified when running the installer).\n\n#### Format\n\nShell commands can be specified in several different ways. The simplest way is just to specify a command as a string containing the command to be run.\n\nAnother way is to specify a two element array where the first element is the shell command and the second is an optional human-readable description.\n\nShell commands support an extended syntax as well, which provides more fine-grained control.\n\n| Parameter | Explanation |\n| --- | --- |\n| `command` | The command to be run |\n| `description` | A human-readable message describing the command (default: null) |\n| `quiet` | Show only the description but not the command in log output (default: false) |\n| `stdin` | Allow a command to read from standard input (default: false) |\n| `stdout` | Show a command's output from stdout (default: false) |\n| `stderr` | Show a command's error output from stderr (default: false) |\n\nNote that `quiet` controls whether the command (a string) is printed in log output, it does not control whether the output from running the command is printed (that is controlled by `stdout` / `stderr`). When a command's `stdin` / `stdout` / `stderr` is not enabled (which is the default), it's connected to `/dev/null`, disabling input and hiding output.\n\n#### Example\n\n```yaml\n- shell:\n  - chsh -s $(which zsh)\n  - [chsh -s $(which zsh), Making zsh the default shell]\n  -\n    command: read var && echo Your variable is $var\n    stdin: true\n    stdout: true\n    description: Reading and printing variable\n    quiet: true\n  -\n    command: read fail\n    stderr: true\n```\n\n### Clean\n\nClean commands specify directories that should be checked for dead symbolic links. These dead links are removed automatically. Only dead links that point to somewhere within the dotfiles directory are removed unless the `force` option is set to `true`.\n\n#### Format\n\nClean commands are specified as an array of directories to be cleaned.\n\nClean commands also support an extended configuration syntax.\n\n| Parameter | Explanation |\n| --- | --- |\n| `force` | Remove dead links even if they don't point to a file inside the dotfiles directory (default: false) |\n| `recursive` | Traverse the directory recursively looking for dead links (default: false) |\n\nNote: using the `recursive` option for `~` is not recommended because it will be slow.\n\n#### Example\n\n```yaml\n- clean: ['~']\n\n- clean:\n    ~/:\n      force: true\n    ~/.config:\n      recursive: true\n```\n\n### Defaults\n\nDefault options for plugins can be specified so that options don't have to be repeated many times. This can be very useful to use with the link command, for example.\n\nDefaults apply to all commands that come after setting the defaults. Defaults can be set multiple times; each change replaces the defaults with a new set of options.\n\n#### Format\n\nDefaults are specified as a dictionary mapping action names to settings, which are dictionaries from option names to values.\n\n#### Example\n\n```yaml\n- defaults:\n    link:\n      create: true\n      relink: true\n```\n\n### Plugins\n\nDotbot also supports custom directives implemented by plugins. Plugins are implemented as subclasses of `dotbot.Plugin`, so they must implement `can_handle()` and `handle()`. The `can_handle()` method should return `True` if the plugin can handle an action with the given name. The `handle()` method should do something and return whether or not it completed successfully.\n\nPlugins should declare support for dry-run with `supports_dry_run = True`, and implement this support by logging what the plugin _would_ do (without doing it) when `Context.dry_run()` is set. Plugins that don't explicitly declare support for dry-run will be skipped when Dotbot is run with `--dry-run`.\n\nAll built-in Dotbot directives are written as plugins that are loaded by default, so those can be used as a reference when writing custom plugins.\n\nSee [here][plugins] for a current list of third-party plugins.\n\n#### Loading plugins via configuration\n\nYou can specify plugins in your configuration file as an array of files or directories (containing plugins) to load:\n\n```yaml\n- plugins:\n    - dotbot-plugins/dotbot-brew/\n    - dotbot-plugins/custom_plugin.py\n```\n\nPaths specified in the config file are interpreted relative to the _base directory_.\n\n#### Loading plugins via command line\n\nPlugins can also be loaded using the `--plugin` option. You can use this argument multiple times:\n\n```bash\ndotbot --plugin dotbot-plugins/dotbot-brew/ --plugin dotbot-plugins/custom_plugin.py ...\n```\n\nPaths specified this way are interpreted relative to the _working directory_ where `dotbot` is invoked.\n\nIt is recommended that these options are added directly to your `install` script for consistency across installations.\n\n## Command-line arguments\n\nDotbot takes a number of command-line arguments; you can run Dotbot with `--help`, for example, by running `./install --help`, to see the full list of options. Here, we highlight a couple that are particularly interesting.\n\n### `--dry-run`\n\nYou can call `./install --dry-run`, and Dotbot will explain what it _would_ do, without actually making any changes. This can be helpful for safely testing your configuration. Plugins that don't support dry-run will be skipped.\n\n### `--only`\n\nYou can call `./install --only [list of directives]`, such as `./install --only link`, and Dotbot will only run those sections of the config file.\n\n### `--except`\n\nYou can call `./install --except [list of directives]`, such as `./install --except shell`, and Dotbot will run all the sections of the config file except the ones listed.\n\n## Wiki\n\nCheck out the [Dotbot wiki][wiki] for more information, tips and tricks, user-contributed plugins, and more.\n\n## Contributing\n\nDo you have a feature request, bug report, or patch? Great! See [CONTRIBUTING.md][contributing] for information on what you can do about that.\n\n## License\n\nCopyright (c) Anish Athalye. Released under the MIT License. See [LICENSE.md][license] for details.\n\n[PyPI]: https://pypi.org/project/dotbot/\n[uv]: https://github.com/astral-sh/uv\n[homebrew-dotbot]: https://formulae.brew.sh/formula/dotbot\n[arch-dotbot]: https://aur.archlinux.org/packages/dotbot\n[init-dotfiles]: https://github.com/Vaelatern/init-dotfiles\n[dotfiles-template]: https://github.com/anishathalye/dotfiles_template\n[inspiration]: https://github.com/anishathalye/dotbot/wiki/Users\n[windows-symlinks]: https://learn.microsoft.com/en-us/windows/security/threat-protection/security-policy-settings/create-symbolic-links\n[json2yaml]: https://www.json2yaml.com/\n[plugins]: https://github.com/anishathalye/dotbot/wiki/Plugins\n[wiki]: https://github.com/anishathalye/dotbot/wiki\n[contributing]: CONTRIBUTING.md\n[license]: LICENSE.md\n"
  },
  {
    "path": "bin/dotbot",
    "content": "#!/usr/bin/env sh\n\n# This is a valid shell script and also a valid Python script. When this file\n# is executed as a shell script, it finds a python binary and executes this\n# file as a Python script, passing along all of the command line arguments.\n# When this file is executed as a Python script, it loads and runs Dotbot. This\n# is useful because we don't know the name of the python binary.\n\n''':' # begin python string; this line is interpreted by the shell as `:`\ncommand -v python3 >/dev/null 2>&1 && exec python3 \"$0\" \"$@\"\ncommand -v python  >/dev/null 2>&1 && exec python  \"$0\" \"$@\"\n>&2 echo \"error: cannot find python\"\nexit 1\n'''\n\n# python code\n\nimport os\nimport sys\n\n# this file is syntactically valid Python 2; bail out if the interpreter is Python 2\nif sys.version_info[0] < 3:\n    print('error: this version of Dotbot is not compatible with Python 2:\\nhttps://github.com/anishathalye/dotbot/wiki/Troubleshooting#python-2')\n    sys.exit(1)\nif sys.version_info < (3, 7):\n    print('error: this version of Dotbot requires Python 3.7+')\n    sys.exit(1)\n\nproject_root_directory = os.path.dirname(\n    os.path.dirname(os.path.realpath(__file__)))\n\ndef inject(lib_path):\n    path = os.path.join(project_root_directory, 'lib', lib_path)\n    sys.path.insert(0, path)\n\ninject('pyyaml/lib')\n\nif os.path.exists(os.path.join(project_root_directory, 'src', 'dotbot')):\n    src_directory = os.path.join(project_root_directory, 'src')\n    if src_directory not in sys.path:\n        sys.path.insert(0, src_directory)\n        os.putenv('PYTHONPATH', src_directory)\n\nimport dotbot\n\ndotbot.cli.main()\n"
  },
  {
    "path": "codecov.yml",
    "content": "coverage:\n  status:\n    project: off\n    patch: off\n"
  },
  {
    "path": "pyproject.toml",
    "content": "[build-system]\nrequires = [\"hatchling\"]\nbuild-backend = \"hatchling.build\"\n\n[project]\nname = \"dotbot\"\nauthors = [\n  { name = \"Anish Athalye\", email = \"me@anishathalye.com\" },\n]\ndescription = \"A tool that bootstraps your dotfiles\"\nreadme = \"README.md\"\nrequires-python = \">=3.7\"\nclassifiers = [\n  \"Development Status :: 5 - Production/Stable\",\n  \"Intended Audience :: Developers\",\n  \"License :: OSI Approved :: MIT License\",\n  \"Programming Language :: Python :: 3\",\n  \"Topic :: Utilities\",\n]\nkeywords = [\"dotfiles\"]\ndynamic = [\"version\"]\ndependencies = [\n  \"PyYAML>=6.0.1,<7\",\n]\n\n[project.scripts]\ndotbot = \"dotbot.cli:main\"\n\n[project.urls]\nhomepage = \"https://github.com/anishathalye/dotbot\"\nrepository = \"https://github.com/anishathalye/dotbot.git\"\nissues = \"https://github.com/anishathalye/dotbot/issues\"\n\n[tool.hatch.version]\npath = \"src/dotbot/__about__.py\"\n\n[tool.hatch.build.targets.sdist]\nexclude = [\n  \"lib/\",\n]\n\n[tool.hatch.envs.default]\ninstaller = \"uv\"\n\n[[tool.hatch.envs.hatch-test.matrix]]\npython = [\"3.7\", \"3.8\", \"3.9\", \"3.10\", \"3.11\", \"3.12\", \"3.13\", \"3.14\", \"pypy3.9\", \"pypy3.10\"]\n\n# the default configuration for the hatch-test environment\n# (https://hatch.pypa.io/latest/config/internal/testing/#dependencies) uses a\n# version of coverage[toml] that is incompatible with Python 3.7 to 3.9, so we override\n# the test dependencies for those Python versions here\n[tool.hatch.envs.hatch-test.overrides]\nname.\"^py(py)?3\\\\.(7|8|9)$\".set-dependencies = [\n  \"coverage-enable-subprocess\",\n  \"coverage[toml]\",\n  \"pytest\",\n  \"pytest-mock\",\n  \"pytest-randomly\",\n  \"pytest-rerunfailures\",\n  \"pytest-xdist[psutil]\",\n]\n\n[tool.coverage.run]\nomit = [\n  \"*/tests/*\",\n  \"*/dotfiles/*\" # the tests create some .py files in a \"dotfiles\" directory\n]\n\n[tool.hatch.envs.types]\nextra-dependencies = [\n  \"mypy>=1.0.0\",\n  \"pytest\",\n  \"types-PyYAML>=6.0.1,<7\",\n]\n\n[tool.hatch.envs.types.scripts]\ncheck = \"mypy {args:src tests .ci}\"\n\n[tool.hatch.envs.coverage]\ndetached = true\ninstaller = \"uv\"\ndependencies = [\n  \"coverage[toml]\",\n]\n\n[tool.hatch.envs.coverage.scripts]\nhtml = \"coverage html\"\nxml = \"coverage xml\"\n\n[tool.mypy]\nstrict = true\n\n[tool.ruff]\nextend-exclude = [\n  \"lib/*.py\"\n]\nlint.ignore = [\n  \"FA100\",\n]\n"
  },
  {
    "path": "src/dotbot/__about__.py",
    "content": "__version__ = \"1.24.1\"\n"
  },
  {
    "path": "src/dotbot/__init__.py",
    "content": "from dotbot.__about__ import __version__\nfrom dotbot.cli import main\nfrom dotbot.plugin import Plugin\n\n__all__ = [\"Plugin\", \"__version__\", \"main\"]\n"
  },
  {
    "path": "src/dotbot/cli.py",
    "content": "import os\nimport subprocess\nimport sys\nfrom argparse import SUPPRESS, ArgumentParser, RawTextHelpFormatter\nfrom typing import Any, List\n\nimport dotbot\nfrom dotbot.config import ConfigReader, ReadingError\nfrom dotbot.dispatcher import Dispatcher, DispatchError\nfrom dotbot.messenger import Level, Messenger\nfrom dotbot.plugins import Clean, Create, Link, Shell\nfrom dotbot.util import module\n\n\ndef add_options(parser: ArgumentParser) -> None:\n    parser.add_argument(\"-Q\", \"--super-quiet\", action=\"store_true\", help=SUPPRESS)  # deprecated\n    parser.add_argument(\"-q\", \"--quiet\", action=\"store_true\", help=\"suppress most output\")\n    parser.add_argument(\n        \"-v\",\n        \"--verbose\",\n        action=\"count\",\n        default=0,\n        help=\"enable verbose output\\n\"\n        \"-v: show informational messages\\n\"\n        \"-vv: also, set shell commands stderr/stdout to true\",\n    )\n    parser.add_argument(\"-d\", \"--base-directory\", help=\"execute commands from within BASE_DIR\", metavar=\"BASE_DIR\")\n    parser.add_argument(\n        \"-c\", \"--config-file\", help=\"run commands given in CONFIG_FILE\", metavar=\"CONFIG_FILE\", nargs=\"+\"\n    )\n    parser.add_argument(\n        \"-p\",\n        \"--plugin\",\n        action=\"append\",\n        dest=\"plugins\",\n        default=[],\n        help=\"load PLUGIN as a plugin\",\n        metavar=\"PLUGIN\",\n    )\n    parser.add_argument(\"--disable-built-in-plugins\", action=\"store_true\", help=\"disable built-in plugins\")\n    parser.add_argument(\n        \"--plugin-dir\",\n        action=\"append\",\n        dest=\"plugin_dirs\",\n        default=[],\n        metavar=\"PLUGIN_DIR\",\n        help=SUPPRESS,  # deprecated\n    )\n    parser.add_argument(\"--only\", nargs=\"+\", help=\"only run specified directives\", metavar=\"DIRECTIVE\")\n    parser.add_argument(\"--except\", nargs=\"+\", dest=\"skip\", help=\"skip specified directives\", metavar=\"DIRECTIVE\")\n    parser.add_argument(\"-n\", \"--dry-run\", action=\"store_true\", help=\"print what would be done, without doing it\")\n    parser.add_argument(\"--force-color\", dest=\"force_color\", action=\"store_true\", help=\"force color output\")\n    parser.add_argument(\"--no-color\", dest=\"no_color\", action=\"store_true\", help=\"disable color output\")\n    parser.add_argument(\"--version\", action=\"store_true\", help=\"show program's version number and exit\")\n    parser.add_argument(\n        \"-x\",\n        \"--exit-on-failure\",\n        dest=\"exit_on_failure\",\n        action=\"store_true\",\n        help=\"exit after first failed directive\",\n    )\n\n\ndef read_config(config_files: List[str]) -> Any:\n    reader = ConfigReader(config_files)\n    return reader.get_config()\n\n\ndef main() -> None:\n    log = Messenger()\n    try:\n        parser = ArgumentParser(formatter_class=RawTextHelpFormatter)\n        add_options(parser)\n        options = parser.parse_args()\n        if options.version:\n            try:\n                with open(os.devnull) as devnull:\n                    git_hash = subprocess.check_output(\n                        [\"git\", \"rev-parse\", \"HEAD\"],  # noqa: S607\n                        cwd=os.path.dirname(os.path.abspath(__file__)),\n                        stderr=devnull,\n                    ).decode(\"ascii\")\n                hash_msg = f\" (git {git_hash[:10]})\"\n            except (OSError, subprocess.CalledProcessError):\n                hash_msg = \"\"\n            print(f\"Dotbot version {dotbot.__version__}{hash_msg}\")  # noqa: T201\n            sys.exit(0)\n        if options.super_quiet or options.quiet:\n            log.set_level(Level.WARNING)\n        if options.verbose > 0:\n            log.set_level(Level.INFO if options.verbose == 1 else Level.DEBUG)\n\n        if options.force_color and options.no_color:\n            log.error(\"`--force-color` and `--no-color` cannot both be provided\")\n            sys.exit(1)\n        elif options.force_color:\n            log.use_color(True)\n        elif options.no_color:\n            log.use_color(False)\n        else:\n            log.use_color(sys.stdout.isatty())\n\n        plugins = []\n        if not options.disable_built_in_plugins:\n            plugins.extend([Clean, Create, Link, Shell])\n        module.load_plugins(options.plugin_dirs, plugins)  # note, plugin_dirs is deprecated\n        module.load_plugins(options.plugins, plugins)\n\n        if not options.config_file:\n            log.error(\"No configuration file specified\")\n            sys.exit(1)\n        tasks = read_config(options.config_file)\n        if not tasks:\n            log.warning(\"No tasks given in configuration, no work to do\")\n        if options.base_directory:\n            base_directory = os.path.abspath(options.base_directory)\n        else:\n            # default to directory of first config file\n            base_directory = os.path.dirname(os.path.abspath(options.config_file[0]))\n        os.chdir(base_directory)\n        dotbot.dispatcher._all_plugins = plugins  # for backwards compatibility, see dispatcher.py  # noqa: SLF001\n        dispatcher = Dispatcher(\n            base_directory,\n            only=options.only,\n            skip=options.skip,\n            exit_on_failure=options.exit_on_failure,\n            options=options,\n            plugins=plugins,\n        )\n        success = dispatcher.dispatch(tasks)\n        if success:\n            log.info(\"All tasks executed successfully\")\n        else:\n            msg = \"Some tasks were not executed successfully\"\n            raise DispatchError(msg)  # noqa: TRY301\n    except (ReadingError, DispatchError) as e:\n        log.error(str(e))  # noqa: TRY400\n        sys.exit(1)\n    except KeyboardInterrupt:\n        log.error(\"Operation aborted\")  # noqa: TRY400\n        sys.exit(1)\n\n\nif __name__ == \"__main__\":\n    main()\n"
  },
  {
    "path": "src/dotbot/config.py",
    "content": "import json\nimport os.path\nfrom typing import Any, List\n\nimport yaml\n\nfrom dotbot.util import string\n\n\nclass ConfigReader:\n    _config: List[Any]\n\n    def __init__(self, config_file_paths: List[str]):\n        self._config = []\n        for path in config_file_paths:\n            config = self._read(path)\n            if config is None:\n                continue\n            if not isinstance(config, list):\n                msg = \"Configuration file must be a list of tasks\"\n                raise ReadingError(msg)\n            self._config.extend(config)\n\n    def _read(self, config_file_path: str) -> Any:\n        try:\n            _, ext = os.path.splitext(config_file_path)\n            with open(config_file_path, encoding=\"utf-8\") as fin:\n                return json.load(fin) if ext == \".json\" else yaml.safe_load(fin)\n        except Exception as e:\n            msg = string.indent_lines(str(e))\n            msg = f\"Could not read config file:\\n{msg}\"\n            raise ReadingError(msg) from e\n\n    def get_config(self) -> Any:\n        return self._config\n\n\nclass ReadingError(Exception):\n    pass\n"
  },
  {
    "path": "src/dotbot/context.py",
    "content": "import copy\nimport os\nfrom argparse import Namespace\nfrom typing import TYPE_CHECKING, Any, Dict, List, Optional, Type\n\nif TYPE_CHECKING:\n    from dotbot.plugin import Plugin\n\n\nclass Context:\n    \"\"\"\n    Contextual data and information for plugins.\n    \"\"\"\n\n    def __init__(\n        self, base_directory: str, options: Optional[Namespace] = None, plugins: \"Optional[List[Type[Plugin]]]\" = None\n    ):\n        self._base_directory = base_directory\n        self._defaults: Dict[str, Any] = {}\n        self._options = options if options is not None else Namespace()\n        self._plugins = plugins\n\n    def set_base_directory(self, base_directory: str) -> None:\n        self._base_directory = base_directory\n\n    def base_directory(self, canonical_path: bool = True) -> str:  # noqa: FBT001, FBT002 # part of established public API\n        base_directory = self._base_directory\n        if canonical_path:\n            base_directory = os.path.realpath(base_directory)\n        return base_directory\n\n    def set_defaults(self, defaults: Dict[str, Any]) -> None:\n        self._defaults = defaults\n\n    def defaults(self) -> Dict[str, Any]:\n        return copy.deepcopy(self._defaults)\n\n    def options(self) -> Namespace:\n        return copy.deepcopy(self._options)\n\n    def plugins(self) -> \"Optional[List[Type[Plugin]]]\":\n        # shallow copy is ok here\n        return copy.copy(self._plugins)\n\n    def dry_run(self) -> bool:\n        return bool(self._options.dry_run)\n"
  },
  {
    "path": "src/dotbot/dispatcher.py",
    "content": "import os\nfrom argparse import Namespace\nfrom typing import Any, Dict, List, Optional, Type\n\nfrom dotbot.context import Context\nfrom dotbot.messenger import Messenger\nfrom dotbot.plugin import Plugin\nfrom dotbot.util.module import load_plugins\n\n# Before b5499c7dc5b300462f3ce1c2a3d9b7a76233b39b, Dispatcher auto-loaded all\n# plugins, but after that change, plugins are passed in explicitly (and loaded\n# in cli.py). There are some plugins that rely on the old Dispatcher behavior,\n# so this is a workaround for implementing similar functionality: when\n# Dispatcher is constructed without an explicit list of plugins, _all_plugins is\n# used instead.\n_all_plugins: List[Type[Plugin]] = []  # filled in by cli.py\n\n\nclass Dispatcher:\n    def __init__(\n        self,\n        base_directory: str,\n        only: Optional[List[str]] = None,\n        skip: Optional[List[str]] = None,\n        exit_on_failure: bool = False,  # noqa: FBT001, FBT002 part of established public API\n        options: Optional[Namespace] = None,\n        plugins: Optional[List[Type[Plugin]]] = None,\n    ):\n        # if the caller wants no plugins, the caller needs to explicitly pass in\n        # plugins=[]\n        self._log = Messenger()\n        self._setup_context(base_directory, options, plugins)\n        if plugins is None:\n            plugins = _all_plugins\n        self._plugins = [plugin(self._context) for plugin in plugins]\n        self._only = only\n        self._skip = skip\n        self._exit = exit_on_failure\n        self._dry_run: bool = options is not None and bool(options.dry_run)\n\n    def _setup_context(\n        self, base_directory: str, options: Optional[Namespace], plugins: Optional[List[Type[Plugin]]]\n    ) -> None:\n        path = os.path.abspath(os.path.expanduser(base_directory))\n        if not os.path.exists(path):\n            msg = \"Nonexistent base directory\"\n            raise DispatchError(msg)\n        self._context = Context(path, options, plugins)\n\n    def dispatch(self, tasks: List[Dict[str, Any]]) -> bool:\n        success = True\n        for task in tasks:\n            for action in task:\n                if (\n                    (self._only is not None and action not in self._only)\n                    or (self._skip is not None and action in self._skip)\n                ) and action != \"defaults\":\n                    self._log.info(f\"Skipping action {action}\")\n                    continue\n                handled = False\n                if action == \"defaults\":\n                    self._context.set_defaults(task[action])  # replace, not update\n                    handled = True\n                    # keep going, let other plugins handle this if they want\n                if action == \"plugins\":\n                    for plugin_path in task[action]:\n                        try:\n                            # load the new plugins and add them to the list of plugins\n                            # this mutates self._context._plugins; we don't add a setter method\n                            # to Context because we don't want plugins to call it\n                            new_plugins = load_plugins([plugin_path], self._context._plugins)  # noqa: SLF001\n                            for plugin_class in new_plugins:\n                                self._plugins.append(plugin_class(self._context))\n                        except Exception as err:  # noqa: BLE001\n                            self._log.warning(f\"Failed to load plugin '{plugin_path}'\")\n                            self._log.debug(str(err))\n                            success = False\n                    if not success:\n                        self._log.error(\"Some plugins could not be loaded\")\n                        if self._exit:\n                            self._log.error(\"Action plugins failed\")\n                            return False\n                    handled = True\n                    # keep going, let other plugins handle this if they want\n                for plugin in self._plugins:\n                    if plugin.can_handle(action):\n                        if self._dry_run and not plugin.supports_dry_run:\n                            self._log.action(f\"Skipping dry-run-unaware plugin {plugin.__class__.__name__}\")\n                            handled = True\n                            continue\n                        try:\n                            local_success = plugin.handle(action, task[action])\n                            if not local_success and self._exit:\n                                # The action has failed, exit\n                                self._log.error(f\"Action {action} failed\")\n                                return False\n                            success &= local_success\n                            handled = True\n                        except Exception as err:  # noqa: BLE001\n                            self._log.error(f\"An error was encountered while executing action {action}\")\n                            self._log.debug(str(err))\n                            if self._exit:\n                                # There was an exception, exit\n                                return False\n                if not handled:\n                    success = False\n                    self._log.error(f\"Action {action} not handled\")\n                    if self._exit:\n                        # Invalid action exit\n                        return False\n        return success\n\n\nclass DispatchError(Exception):\n    pass\n"
  },
  {
    "path": "src/dotbot/messenger/__init__.py",
    "content": "from dotbot.messenger.level import Level\nfrom dotbot.messenger.messenger import Messenger\n\n__all__ = [\"Level\", \"Messenger\"]\n"
  },
  {
    "path": "src/dotbot/messenger/color.py",
    "content": "class Color:\n    NONE = \"\"\n    RESET = \"\\033[0m\"\n    RED = \"\\033[91m\"\n    GREEN = \"\\033[92m\"\n    YELLOW = \"\\033[93m\"\n    BLUE = \"\\033[94m\"\n    MAGENTA = \"\\033[95m\"\n"
  },
  {
    "path": "src/dotbot/messenger/level.py",
    "content": "from enum import Enum\nfrom typing import Any\n\n\nclass Level(Enum):\n    NOTSET = 0\n    DEBUG = 10\n    INFO = 15\n    LOWINFO = 15  # Deprecated: use INFO instead  # noqa: PIE796\n    ACTION = 20\n    WARNING = 30\n    ERROR = 40\n\n    def __lt__(self, other: Any) -> bool:\n        if not isinstance(other, Level):\n            return NotImplemented\n        return self.value < other.value\n\n    def __le__(self, other: Any) -> bool:\n        if not isinstance(other, Level):\n            return NotImplemented\n        return self.value <= other.value\n\n    def __gt__(self, other: Any) -> bool:\n        if not isinstance(other, Level):\n            return NotImplemented\n        return self.value > other.value\n\n    def __ge__(self, other: Any) -> bool:\n        if not isinstance(other, Level):\n            return NotImplemented\n        return self.value >= other.value\n\n    def __eq__(self, other: object) -> bool:\n        if not isinstance(other, Level):\n            return NotImplemented\n        return self.value == other.value\n\n    def __hash__(self) -> int:\n        return hash(self.value)\n"
  },
  {
    "path": "src/dotbot/messenger/messenger.py",
    "content": "from dotbot.messenger.color import Color\nfrom dotbot.messenger.level import Level\nfrom dotbot.util.singleton import Singleton\n\n\nclass Messenger(metaclass=Singleton):\n    def __init__(self, level: Level = Level.ACTION):\n        self.set_level(level)\n        self.use_color(True)\n\n    def set_level(self, level: Level) -> None:\n        self._level = level\n\n    def use_color(self, yesno: bool) -> None:  # noqa: FBT001\n        self._use_color = yesno\n\n    def log(self, level: Level, message: str) -> None:\n        if level >= self._level:\n            print(f\"{self._color(level)}{message}{self._reset()}\")  # noqa: T201\n\n    def debug(self, message: str) -> None:\n        self.log(Level.DEBUG, message)\n\n    def action(self, message: str) -> None:\n        self.log(Level.ACTION, message)\n\n    def info(self, message: str) -> None:\n        self.log(Level.INFO, message)\n\n    def lowinfo(self, message: str) -> None:\n        \"\"\"Deprecated: use info() or action() instead.\"\"\"\n        self.info(message)\n\n    def warning(self, message: str) -> None:\n        self.log(Level.WARNING, message)\n\n    def error(self, message: str) -> None:\n        self.log(Level.ERROR, message)\n\n    def _color(self, level: Level) -> str:\n        \"\"\"\n        Get a color (terminal escape sequence) according to a level.\n        \"\"\"\n        if not self._use_color or level < Level.DEBUG:\n            return \"\"\n        if level < Level.INFO:\n            return Color.YELLOW\n        if level < Level.ACTION:\n            return Color.BLUE\n        if level < Level.WARNING:\n            return Color.GREEN\n        if level < Level.ERROR:\n            return Color.MAGENTA\n        return Color.RED\n\n    def _reset(self) -> str:\n        \"\"\"\n        Get a reset color (terminal escape sequence).\n        \"\"\"\n        if not self._use_color:\n            return \"\"\n        return Color.RESET\n"
  },
  {
    "path": "src/dotbot/plugin.py",
    "content": "from typing import Any\n\nfrom dotbot.context import Context\nfrom dotbot.messenger import Messenger\n\n\nclass Plugin:\n    \"\"\"\n    Abstract base class for commands that process directives.\n    \"\"\"\n\n    _context: Context\n    _log: Messenger\n    supports_dry_run: bool = False  # plugins must explicitly declare support for dry-run mode\n\n    def __init__(self, context: Context):\n        self._context = context\n        self._log = Messenger()\n\n    def can_handle(self, directive: str) -> bool:\n        \"\"\"\n        Returns true if the Plugin can handle the directive.\n        \"\"\"\n        raise NotImplementedError\n\n    def handle(self, directive: str, data: Any) -> bool:\n        \"\"\"\n        Executes the directive.\n\n        Returns true if the Plugin successfully handled the directive.\n        \"\"\"\n        raise NotImplementedError\n"
  },
  {
    "path": "src/dotbot/plugins/__init__.py",
    "content": "from dotbot.plugins.clean import Clean\nfrom dotbot.plugins.create import Create\nfrom dotbot.plugins.link import Link\nfrom dotbot.plugins.shell import Shell\n\n__all__ = [\"Clean\", \"Create\", \"Link\", \"Shell\"]\n"
  },
  {
    "path": "src/dotbot/plugins/clean.py",
    "content": "import os\nimport sys\nfrom typing import Any\n\nfrom dotbot.plugin import Plugin\nfrom dotbot.util.common import normslash\n\n\nclass Clean(Plugin):\n    \"\"\"\n    Cleans broken symbolic links.\n    \"\"\"\n\n    supports_dry_run = True\n\n    _directive = \"clean\"\n\n    def can_handle(self, directive: str) -> bool:\n        return directive == self._directive\n\n    def handle(self, directive: str, data: Any) -> bool:\n        if directive != self._directive:\n            msg = f\"Clean cannot handle directive {directive}\"\n            raise ValueError(msg)\n        return self._process_clean(data)\n\n    def _process_clean(self, targets: Any) -> bool:\n        success = True\n        defaults = self._context.defaults().get(self._directive, {})\n        for target in targets:\n            force = defaults.get(\"force\", False)\n            recursive = defaults.get(\"recursive\", False)\n            if isinstance(targets, dict) and isinstance(targets[target], dict):\n                force = targets[target].get(\"force\", force)\n                recursive = targets[target].get(\"recursive\", recursive)\n            success &= self._clean(normslash(target), force=force, recursive=recursive)\n        if success:\n            self._log.info(\"All targets have been cleaned\")\n        else:\n            self._log.error(\"Some targets were not successfully cleaned\")\n        return success\n\n    def _clean(self, target: str, *, force: bool, recursive: bool) -> bool:\n        \"\"\"\n        Cleans all the broken symbolic links in target if they point to\n        a subdirectory of the base directory or if forced to clean.\n        \"\"\"\n        if not os.path.isdir(os.path.expandvars(os.path.expanduser(target))):\n            self._log.debug(f\"Ignoring nonexistent directory {target}\")\n            return True\n        for item in os.listdir(os.path.expandvars(os.path.expanduser(target))):\n            path = os.path.abspath(os.path.join(os.path.expandvars(os.path.expanduser(target)), item))\n            if recursive and os.path.isdir(path):\n                # isdir implies not islink -- we don't want to descend into\n                # symlinked directories. okay to do a recursive call here\n                # because depth should be fairly limited\n                self._clean(path, force=force, recursive=recursive)\n            if not os.path.exists(path) and os.path.islink(path):\n                points_at = os.path.join(os.path.dirname(path), os.readlink(path))\n                if sys.platform == \"win32\" and points_at.startswith(\"\\\\\\\\?\\\\\"):\n                    points_at = points_at[4:]\n                if self._in_directory(path, self._context.base_directory()) or force:\n                    if self._context.dry_run():\n                        self._log.action(f\"Would remove invalid link {path} -> {points_at}\")\n                    else:\n                        self._log.action(f\"Removing invalid link {path} -> {points_at}\")\n                        os.remove(path)\n                else:\n                    self._log.info(f\"Link {path} -> {points_at} not removed.\")\n        return True\n\n    def _in_directory(self, path: str, directory: str) -> bool:\n        \"\"\"\n        Returns true if the path is in the directory.\n        \"\"\"\n        directory = os.path.join(os.path.realpath(directory), \"\")\n        path = os.path.realpath(path)\n        return os.path.commonprefix([path, directory]) == directory\n"
  },
  {
    "path": "src/dotbot/plugins/create.py",
    "content": "import os\nfrom typing import Any\n\nfrom dotbot.plugin import Plugin\nfrom dotbot.util.common import normslash\n\n\nclass Create(Plugin):\n    \"\"\"\n    Create empty paths.\n    \"\"\"\n\n    supports_dry_run = True\n\n    _directive = \"create\"\n\n    def can_handle(self, directive: str) -> bool:\n        return directive == self._directive\n\n    def handle(self, directive: str, data: Any) -> bool:\n        if directive != self._directive:\n            msg = f\"Create cannot handle directive {directive}\"\n            raise ValueError(msg)\n        return self._process_paths(data)\n\n    def _process_paths(self, paths: Any) -> bool:\n        success = True\n        defaults = self._context.defaults().get(\"create\", {})\n        for key in paths:\n            path = os.path.abspath(os.path.expandvars(os.path.expanduser(normslash(key))))\n            mode = defaults.get(\"mode\", 0o777)  # same as the default for os.makedirs\n            if isinstance(paths, dict):\n                options = paths[key]\n                if options:\n                    mode = options.get(\"mode\", mode)\n            success &= self._create(path, mode)\n        if success:\n            self._log.info(\"All paths have been set up\")\n        else:\n            self._log.error(\"Some paths were not successfully set up\")\n        return success\n\n    def _exists(self, path: str) -> bool:\n        \"\"\"\n        Returns true if the path exists.\n        \"\"\"\n        path = os.path.expanduser(path)\n        return os.path.exists(path)\n\n    def _create(self, path: str, mode: int) -> bool:\n        success = True\n        if not self._exists(path):\n            self._log.debug(f\"Trying to create path {path} with mode {mode}\")\n            try:\n                if self._context.dry_run():\n                    self._log.action(f\"Would create path {path}\")\n                    return True\n                self._log.action(f\"Creating path {path}\")\n                os.makedirs(path, mode)\n                # On Windows, the *mode* argument to `os.makedirs()` is ignored.\n                # The mode must be set explicitly in a follow-up call.\n                os.chmod(path, mode)\n            except OSError:\n                self._log.warning(f\"Failed to create path {path}\")\n                success = False\n        else:\n            self._log.info(f\"Path exists {path}\")\n        return success\n"
  },
  {
    "path": "src/dotbot/plugins/link.py",
    "content": "import glob\nimport os\nimport shutil\nimport sys\nfrom datetime import datetime, timezone\nfrom typing import Any, List, Optional, Tuple\n\nfrom dotbot.plugin import Plugin\nfrom dotbot.util import shell_command\nfrom dotbot.util.common import normslash\n\n\nclass Link(Plugin):\n    \"\"\"\n    Symbolically links dotfiles.\n    \"\"\"\n\n    supports_dry_run = True\n\n    _directive = \"link\"\n\n    def can_handle(self, directive: str) -> bool:\n        return directive == self._directive\n\n    def handle(self, directive: str, data: Any) -> bool:\n        if directive != self._directive:\n            msg = f\"Link cannot handle directive {directive}\"\n            raise ValueError(msg)\n        return self._process_links(data)\n\n    def _process_links(self, links: Any) -> bool:\n        success = True\n        defaults = self._context.defaults().get(\"link\", {})\n\n        # Validate the default link type before looping.\n        link_type = defaults.get(\"type\", \"symlink\")\n        if link_type not in {\"symlink\", \"hardlink\"}:\n            self._log.warning(f\"The default link type is not recognized: '{link_type}'\")\n            return False\n\n        for link_name, target in links.items():\n            link_name = os.path.expandvars(normslash(link_name))  # noqa: PLW2901\n            relative = defaults.get(\"relative\", False)\n            # support old \"canonicalize-path\" key for compatibility\n            canonical_path = defaults.get(\"canonicalize\", defaults.get(\"canonicalize-path\", True))\n            link_type = defaults.get(\"type\", \"symlink\")\n            force = defaults.get(\"force\", False)\n            relink = defaults.get(\"relink\", False)\n            create = defaults.get(\"create\", False)\n            use_glob = defaults.get(\"glob\", False)\n            backup = defaults.get(\"backup\", False)\n            base_prefix = defaults.get(\"prefix\", \"\")\n            test = defaults.get(\"if\", None)\n            ignore_missing = defaults.get(\"ignore-missing\", False)\n            exclude_paths = defaults.get(\"exclude\", [])\n            if isinstance(target, dict):\n                # extended config\n                test = target.get(\"if\", test)\n                relative = target.get(\"relative\", relative)\n                canonical_path = target.get(\"canonicalize\", target.get(\"canonicalize-path\", canonical_path))\n                link_type = target.get(\"type\", link_type)\n                if link_type not in {\"symlink\", \"hardlink\"}:\n                    msg = f\"The link type is not recognized: '{link_type}'\"\n                    self._log.warning(msg)\n                    success = False\n                    continue\n                force = target.get(\"force\", force)\n                relink = target.get(\"relink\", relink)\n                create = target.get(\"create\", create)\n                use_glob = target.get(\"glob\", use_glob)\n                backup = target.get(\"backup\", backup)\n                base_prefix = target.get(\"prefix\", base_prefix)\n                ignore_missing = target.get(\"ignore-missing\", ignore_missing)\n                exclude_paths = target.get(\"exclude\", exclude_paths)\n                path = self._default_target(link_name, target.get(\"path\"))\n            else:\n                path = self._default_target(link_name, target)\n            path = normslash(path)\n            if test is not None and not self._test_success(test):\n                self._log.info(f\"Skipping {link_name}\")\n                continue\n            path = os.path.normpath(os.path.expandvars(os.path.expanduser(path)))\n            if use_glob and self._has_glob_chars(path):\n                glob_results = self._create_glob_results(path, exclude_paths)\n                self._log.debug(f\"Globs from '{path}': {glob_results}\")\n                for glob_full_item in glob_results:\n                    # Find common dirname between pattern and the item:\n                    glob_dirname = os.path.dirname(os.path.commonprefix([path, glob_full_item]))\n                    glob_item = glob_full_item if len(glob_dirname) == 0 else glob_full_item[len(glob_dirname) + 1 :]\n                    # Add prefix to basepath, if provided\n                    if base_prefix:\n                        glob_item = base_prefix + glob_item\n                    # where is it going\n                    glob_link_name = os.path.join(link_name, glob_item)\n                    if create:\n                        success &= self._create(glob_link_name)\n                    did_backup = False\n                    did_delete = False\n                    if backup:\n                        did_backup, backup_success = self._backup(glob_link_name)\n                        success &= backup_success\n                    # we only need to consider force/relink if we didn't do a backup\n                    if (force or relink) and not (did_backup and backup_success):\n                        did_delete, delete_success = self._delete(\n                            glob_full_item,\n                            glob_link_name,\n                            relative=relative,\n                            canonical_path=canonical_path,\n                            force=force,\n                        )\n                        success &= delete_success\n                    success &= self._link(\n                        glob_full_item,\n                        glob_link_name,\n                        relative=relative,\n                        canonical_path=canonical_path,\n                        ignore_missing=ignore_missing,\n                        link_type=link_type,\n                        assume_gone=(did_backup or did_delete),\n                    )\n            else:\n                if create:\n                    success &= self._create(link_name)\n                if not ignore_missing and not self._exists(os.path.join(self._context.base_directory(), path)):\n                    # we seemingly check this twice (here and in _link) because\n                    # if the file doesn't exist and force is True, we don't\n                    # want to remove the original (this is tested by test_link_force_leaves_when_nonexistent)\n                    success = False\n                    self._log.warning(f\"Nonexistent target {link_name} -> {path}\")\n                    continue\n                did_backup = False\n                did_delete = False\n                if backup:\n                    did_backup, backup_success = self._backup(link_name)\n                    success &= backup_success\n                # we only need to consider force/relink if we didn't do a backup\n                if (force or relink) and not (did_backup and backup_success):\n                    did_delete, delete_success = self._delete(\n                        path, link_name, relative=relative, canonical_path=canonical_path, force=force\n                    )\n                    success &= delete_success\n                success &= self._link(\n                    path,\n                    link_name,\n                    relative=relative,\n                    canonical_path=canonical_path,\n                    ignore_missing=ignore_missing,\n                    link_type=link_type,\n                    assume_gone=(did_backup or did_delete),\n                )\n        if success:\n            self._log.info(\"All links have been set up\")\n        else:\n            self._log.error(\"Some links were not successfully set up\")\n        return success\n\n    def _test_success(self, command: str) -> bool:\n        ret = shell_command(command, cwd=self._context.base_directory())\n        if ret != 0:\n            self._log.debug(f\"Test '{command}' returned false\")\n        return ret == 0\n\n    def _default_target(self, link_name: str, target: Optional[str]) -> str:\n        if target is None:\n            basename = os.path.basename(link_name)\n            if basename.startswith(\".\"):\n                return basename[1:]\n            return basename\n        return target\n\n    def _has_glob_chars(self, path: str) -> bool:\n        return any(i in path for i in \"?*[\")\n\n    def _glob(self, path: str) -> List[str]:\n        \"\"\"\n        Wrap `glob.glob` in a python agnostic way, catching errors in usage.\n        \"\"\"\n        found = glob.glob(path, recursive=True)\n        # normalize paths to ensure cross-platform compatibility\n        found = [os.path.normpath(p) for p in found]\n        # if using recursive glob (`**`), filter results to return only files:\n        if \"**\" in path and not path.endswith(str(os.sep)):\n            self._log.debug(\"Excluding directories from recursive glob: \" + str(path))\n            found = [f for f in found if os.path.isfile(f)]\n        # return matched results\n        return found\n\n    def _create_glob_results(self, path: str, exclude_paths: List[str]) -> List[str]:\n        self._log.debug(\"Globbing with pattern: \" + str(path))\n        include = self._glob(path)\n        self._log.debug(\"Glob found : \" + str(include))\n        # filter out any paths matching the exclude globs:\n        exclude = []\n        for expat in exclude_paths:\n            self._log.debug(\"Excluding globs with pattern: \" + str(expat))\n            exclude.extend(self._glob(expat))\n        self._log.debug(\"Excluded globs from '\" + path + \"': \" + str(exclude))\n        ret = set(include) - set(exclude)\n        return list(ret)\n\n    def _is_link(self, path: str) -> bool:\n        \"\"\"\n        Returns true if the path is a symbolic link.\n        \"\"\"\n        return os.path.islink(os.path.expanduser(path))\n\n    def _link_target(self, path: str) -> str:\n        \"\"\"\n        Returns the target of the symbolic link.\n        \"\"\"\n        path = os.path.expanduser(path)\n        path = os.readlink(path)\n        if sys.platform == \"win32\" and path.startswith(\"\\\\\\\\?\\\\\"):\n            path = path[4:]\n        return path\n\n    def _exists(self, path: str) -> bool:\n        \"\"\"\n        Returns true if the path exists.\n        \"\"\"\n        path = os.path.expanduser(path)\n        return os.path.exists(path)\n\n    def _lexists(self, path: str) -> bool:\n        \"\"\"\n        Returns true if the path exists (including broken symlinks).\n        \"\"\"\n        path = os.path.expanduser(path)\n        return os.path.lexists(path)\n\n    def _create(self, path: str) -> bool:\n        success = True\n        parent = os.path.abspath(os.path.join(os.path.expanduser(path), os.pardir))\n        if not self._exists(parent):\n            self._log.debug(f\"Try to create parent: {parent}\")\n            if self._context.dry_run():\n                self._log.action(f\"Would create directory {parent}\")\n                return True\n            try:\n                os.makedirs(parent)\n            except OSError as e:\n                self._log.warning(f\"Failed to create directory {parent}\")\n                self._log.debug(f\"OSError: {e!s}\")\n                success = False\n            else:\n                self._log.action(f\"Creating directory {parent}\")\n        return success\n\n    def _backup(self, path: str) -> Tuple[bool, bool]:\n        if self._exists(path) and not self._is_link(path):\n            timestamp = datetime.now(timezone.utc).astimezone().strftime(\"%Y%m%d-%H%M%S\")\n            backup_name = f\"{path}.dotbot-backup.{timestamp}\"\n            self._log.debug(f\"Try to backup file {path} to {backup_name}\")\n            if self._context.dry_run():\n                self._log.action(f\"Would backup {path} to {backup_name}\")\n                return True, True\n            try:\n                os.rename(os.path.abspath(os.path.expanduser(path)), os.path.abspath(os.path.expanduser(backup_name)))\n            except OSError as e:\n                self._log.warning(f\"Failed to backup file {path} to {backup_name}\")\n                self._log.debug(f\"OSError: {e!s}\")\n                return False, False\n            else:\n                self._log.action(f\"Backed up file {path} to {backup_name}\")\n                return True, True\n        return False, True\n\n    def _delete(\n        self, target: str, path: str, *, relative: bool, canonical_path: bool, force: bool\n    ) -> Tuple[bool, bool]:\n        success = True\n        removed = False\n        target = os.path.join(self._context.base_directory(canonical_path=canonical_path), target)\n        fullpath = os.path.abspath(os.path.expanduser(path))\n        if self._exists(path) and not self._is_link(path) and os.path.realpath(fullpath) == target:\n            # Special case: The path is not a symlink but resolves to the target anyway.\n            # Deleting the path would actually delete the target.\n            # This may happen if a parent directory is a symlink.\n            self._log.warning(f\"{path} appears to be the same file as {target}.\")\n            return False, False\n        if relative:\n            target = self._relative_path(target, fullpath)\n        if (self._is_link(path) and self._link_target(path) != target) or (\n            self._lexists(path) and not self._is_link(path)\n        ):\n            if self._context.dry_run():\n                self._log.action(f\"Would remove {path}\")\n                removed = True\n            else:\n                try:\n                    if os.path.islink(fullpath):\n                        os.unlink(fullpath)\n                        removed = True\n                    elif force:\n                        if os.path.isdir(fullpath):\n                            shutil.rmtree(fullpath)\n                            removed = True\n                        else:\n                            os.remove(fullpath)\n                            removed = True\n                except OSError as e:\n                    self._log.warning(f\"Failed to remove {path}\")\n                    self._log.debug(f\"OSError: {e!s}\")\n                    success = False\n                else:\n                    if removed:\n                        self._log.action(f\"Removing {path}\")\n        return removed, success\n\n    def _relative_path(self, target: str, link_name: str) -> str:\n        \"\"\"\n        Returns the relative path to get to the target file from the\n        link location.\n        \"\"\"\n        link_dir = os.path.dirname(link_name)\n        return os.path.relpath(target, link_dir)\n\n    def _link(\n        self,\n        target: str,\n        link_name: str,\n        *,\n        relative: bool,\n        canonical_path: bool,\n        ignore_missing: bool,\n        link_type: str,\n        assume_gone: bool,\n    ) -> bool:\n        \"\"\"\n        Links link_name to target.\n\n        The caller must ensure that the target exists.\n\n        Returns true if successfully linked files.\n        \"\"\"\n\n        link_path = os.path.abspath(os.path.expanduser(link_name))\n        base_directory = self._context.base_directory(canonical_path=canonical_path)\n        absolute_target = os.path.join(base_directory, target)\n        link_name = os.path.normpath(link_name)\n        target_path = self._relative_path(absolute_target, link_path) if relative else absolute_target\n\n        # we need to use absolute_target below because our cwd is the dotfiles\n        # directory, and if target_path is relative, it will be relative to the\n        # link directory\n        if ((not self._lexists(link_name)) or (self._context.dry_run() and assume_gone)) and (\n            ignore_missing or self._exists(absolute_target)\n        ):\n            if self._context.dry_run():\n                self._log.action(f\"Would create {link_type} {link_name} -> {target_path}\")\n                return True\n            try:\n                if link_type == \"symlink\":\n                    os.symlink(target_path, link_path)\n                else:  # link_type == \"hardlink\"\n                    os.link(absolute_target, link_path)\n            except OSError as e:\n                self._log.warning(f\"Linking failed {link_name} -> {target_path}\")\n                self._log.debug(f\"OSError: {e!s}\")\n                return False\n            else:\n                self._log.action(f\"Creating {link_type} {link_name} -> {target_path}\")\n                return True\n\n        # Failure case: The link name exists and is a symlink\n        if self._is_link(link_name):\n            if link_type == \"symlink\":\n                if self._link_target(link_name) == target_path:\n                    # Idempotent case: The configured symlink already exists\n                    self._log.info(f\"Link exists {link_name} -> {target_path}\")\n                    return True\n\n                # The existing symlink isn't pointing at the target.\n                # Distinguish between an incorrect symlink and a broken (\"invalid\") symlink.\n                terminology = \"Incorrect\" if self._exists(link_name) else \"Invalid\"\n                self._log.warning(f\"{terminology} link {link_name} -> {self._link_target(link_name)}\")\n                return False\n\n            self._log.warning(f\"{link_name} already exists but is a symbolic link, not a hard link\")\n            return False\n\n        # Failure case: The link name exists\n        if link_type == \"hardlink\" and os.stat(link_path).st_ino == os.stat(absolute_target).st_ino:\n            # Idempotent case: The configured hardlink already exists\n            self._log.info(f\"Link exists {link_name} -> {target_path}\")\n            return True\n\n        self._log.warning(f\"{link_name} already exists but is a regular file or directory\")\n        return False\n"
  },
  {
    "path": "src/dotbot/plugins/shell.py",
    "content": "from typing import Any, Dict\n\nfrom dotbot.plugin import Plugin\nfrom dotbot.util.common import shell_command\n\n\nclass Shell(Plugin):\n    \"\"\"\n    Run arbitrary shell commands.\n    \"\"\"\n\n    supports_dry_run = True\n\n    _directive = \"shell\"\n    _has_shown_override_message = False\n\n    def can_handle(self, directive: str) -> bool:\n        return directive == self._directive\n\n    def handle(self, directive: str, data: Any) -> bool:\n        if directive != self._directive:\n            msg = f\"Shell cannot handle directive {directive}\"\n            raise ValueError(msg)\n        return self._process_commands(data)\n\n    def _process_commands(self, data: Any) -> bool:\n        success = True\n        defaults = self._context.defaults().get(\"shell\", {})\n        options = self._get_option_overrides()\n        for item in data:\n            stdin = defaults.get(\"stdin\", False)\n            stdout = defaults.get(\"stdout\", False)\n            stderr = defaults.get(\"stderr\", False)\n            quiet = defaults.get(\"quiet\", False)\n            if isinstance(item, dict):\n                cmd = item[\"command\"]\n                msg = item.get(\"description\", None)\n                stdin = item.get(\"stdin\", stdin)\n                stdout = item.get(\"stdout\", stdout)\n                stderr = item.get(\"stderr\", stderr)\n                quiet = item.get(\"quiet\", quiet)\n            elif isinstance(item, list):\n                cmd = item[0]\n                msg = item[1] if len(item) > 1 else None\n            else:\n                cmd = item\n                msg = None\n            prefix = \"Would run command \" if self._context.dry_run() else \"\"\n            if quiet:\n                if msg is not None:\n                    self._log.info(f\"{prefix}{msg}\")\n                # if quiet and no msg, show nothing\n            elif msg is None:\n                self._log.action(f\"{prefix}{cmd}\")\n            else:\n                self._log.action(f\"{prefix}{msg} [{cmd}]\")\n            if self._context.dry_run():\n                continue\n            stdout = options.get(\"stdout\", stdout)\n            stderr = options.get(\"stderr\", stderr)\n            ret = shell_command(\n                cmd,\n                cwd=self._context.base_directory(),\n                enable_stdin=stdin,\n                enable_stdout=stdout,\n                enable_stderr=stderr,\n            )\n            if ret != 0:\n                success = False\n                self._log.warning(f\"Command [{cmd}] failed\")\n        if success:\n            self._log.info(\"All commands have been executed\")\n        else:\n            self._log.error(\"Some commands were not successfully executed\")\n        return success\n\n    def _get_option_overrides(self) -> Dict[str, bool]:\n        ret = {}\n        options = self._context.options()\n        if options.verbose > 1:\n            ret[\"stderr\"] = True\n            ret[\"stdout\"] = True\n            if not self._has_shown_override_message:\n                self._log.debug(\"Shell: Found cli option to force show stderr and stdout.\")\n                self._has_shown_override_message = True\n        return ret\n"
  },
  {
    "path": "src/dotbot/util/__init__.py",
    "content": "from dotbot.util.common import shell_command\n\n__all__ = [\"shell_command\"]\n"
  },
  {
    "path": "src/dotbot/util/common.py",
    "content": "import os\nimport platform\nimport subprocess\nimport sys\nfrom typing import Optional\n\n\ndef shell_command(\n    command: str,\n    cwd: Optional[str] = None,\n    *,\n    enable_stdin: bool = False,\n    enable_stdout: bool = False,\n    enable_stderr: bool = False,\n) -> int:\n    with open(os.devnull, \"w\") as devnull_w, open(os.devnull) as devnull_r:\n        stdin = None if enable_stdin else devnull_r\n        stdout = None if enable_stdout else devnull_w\n        stderr = None if enable_stderr else devnull_w\n        executable = os.environ.get(\"SHELL\")\n        if platform.system() == \"Windows\":\n            # We avoid setting the executable kwarg on Windows because it does\n            # not have the desired effect when combined with shell=True. It\n            # will result in the correct program being run (e.g. bash), but it\n            # will be invoked with a '/c' argument instead of a '-c' argument,\n            # which it won't understand.\n            #\n            # See https://github.com/anishathalye/dotbot/issues/219 and\n            # https://bugs.python.org/issue40467.\n            #\n            # This means that complex commands that require Bash's parsing\n            # won't work; a workaround for this is to write the command as\n            # `bash -c \"...\"`.\n            executable = None\n        return subprocess.call(  # noqa: S602\n            command,\n            shell=True,\n            executable=executable,\n            stdin=stdin,\n            stdout=stdout,\n            stderr=stderr,\n            cwd=cwd,\n        )\n\n\ndef normslash(path: str) -> str:\n    if sys.platform == \"win32\":\n        # this is how normcase in cpython/Lib/ntpath.py does it; we don't use normcase\n        # because we don't want to make all characters lowercase\n        return path.replace(\"/\", \"\\\\\")\n    return path\n"
  },
  {
    "path": "src/dotbot/util/module.py",
    "content": "import glob\nimport importlib.util\nimport os\nfrom types import ModuleType\nfrom typing import List, Optional, Type\n\nfrom dotbot.plugin import Plugin\n\n# We keep references to loaded modules so they don't get garbage collected.\nloaded_modules = []\n\n\ndef load(path: str) -> List[Type[Plugin]]:\n    basename = os.path.basename(path)\n    module_name, _ = os.path.splitext(basename)\n    loaded_module = load_module(module_name, path)\n    plugins = []\n    for name in dir(loaded_module):\n        possible_plugin = getattr(loaded_module, name)\n        try:\n            if issubclass(possible_plugin, Plugin) and possible_plugin is not Plugin:\n                plugins.append(possible_plugin)\n        except TypeError:\n            pass\n    loaded_modules.append(loaded_module)\n    return plugins\n\n\ndef load_module(module_name: str, path: str) -> ModuleType:\n    spec = importlib.util.spec_from_file_location(module_name, path)\n    if not spec or not spec.loader:\n        msg = f\"Unable to load module {module_name} from {path}\"\n        raise ImportError(msg)\n    module = importlib.util.module_from_spec(spec)\n    spec.loader.exec_module(module)\n    return module\n\n\ndef load_plugins(paths: List[str], plugins: Optional[List[Type[Plugin]]] = None) -> List[Type[Plugin]]:\n    \"\"\"\n    Load plugins from the given paths and add them to the given list of plugins.\n\n    Args:\n        paths: List of file paths to load plugins from. Each path can be either a file or a directory.\n        plugins: List of existing plugins to add to.\n\n    Returns the newly-loaded plugins.\n    \"\"\"\n    if plugins is None:\n        plugins = []\n    new_plugins = []\n    plugin_paths = []\n    for path in paths:\n        if os.path.isdir(path):\n            plugin_paths.extend(glob.glob(os.path.join(path, \"*.py\")))\n        else:\n            plugin_paths.append(path)\n    for plugin_path in plugin_paths:\n        abspath = os.path.abspath(plugin_path)\n        for plugin in load(abspath):\n            # ensure plugins are unique to avoid duplicate execution, which\n            # can happen if, for example, a third-party plugin loads a\n            # built-in plugin, which will cause it to appear in the list\n            # returned by load() above\n            plugin_already_loaded = any(\n                existing_plugin.__module__ == plugin.__module__ and existing_plugin.__name__ == plugin.__name__\n                for existing_plugin in plugins\n            )\n            if not plugin_already_loaded:\n                plugins.append(plugin)\n                new_plugins.append(plugin)\n    return new_plugins\n"
  },
  {
    "path": "src/dotbot/util/singleton.py",
    "content": "from typing import Any\n\n\nclass Singleton(type):\n    def __call__(cls, *args: Any, **kwargs: Any) -> Any:\n        if not hasattr(cls, \"_singleton_instance\"):\n            cls._singleton_instance = super().__call__(*args, **kwargs)\n        return cls._singleton_instance\n\n    def reset_instance(cls) -> None:\n        if hasattr(cls, \"_singleton_instance\"):\n            del cls._singleton_instance\n"
  },
  {
    "path": "src/dotbot/util/string.py",
    "content": "def indent_lines(string: str, amount: int = 2, delimiter: str = \"\\n\") -> str:\n    whitespace = \" \" * amount\n    sep = f\"{delimiter}{whitespace}\"\n    return f\"{whitespace}{sep.join(string.split(delimiter))}\"\n"
  },
  {
    "path": "tests/__init__.py",
    "content": ""
  },
  {
    "path": "tests/conftest.py",
    "content": "import builtins\nimport ctypes\nimport json\nimport os\nimport shutil\nimport sys\nimport tempfile\nfrom shutil import rmtree\nfrom typing import Any, Callable, Generator, List, Optional\nfrom unittest import mock\n\nimport pytest\nimport yaml\n\nimport dotbot.cli\n\n\ndef get_long_path(path: str) -> str:\n    \"\"\"Get the long path for a given path.\"\"\"\n\n    # Do nothing for non-Windows platforms.\n    if sys.platform != \"win32\":\n        return path\n\n    buffer_size = 1000\n    buffer = ctypes.create_unicode_buffer(buffer_size)\n    get_long_path_name = ctypes.windll.kernel32.GetLongPathNameW\n    get_long_path_name(path, buffer, buffer_size)\n    return buffer.value\n\n\n# On Linux, tempfile.TemporaryFile() requires unlink access.\n# This list is updated by a tempfile._mkstemp_inner() wrapper,\n# and its contents are checked by wrapped functions.\nallowed_tempfile_internal_unlink_calls: List[str] = []\n\n\ndef get_path_from_fd(fd: int) -> Optional[str]:\n    \"\"\"Get the filesystem path for a file descriptor.\n\n    Returns None if the path cannot be determined (e.g., on Windows or if fd is invalid).\n    \"\"\"\n    try:\n        if sys.platform == \"linux\":\n            return os.readlink(f\"/proc/self/fd/{fd}\")\n        if sys.platform == \"darwin\":\n            import fcntl  # noqa: PLC0415\n\n            f_getpath = getattr(fcntl, \"F_GETPATH\", 50)  # Python 3.9+ exposes fcntl.F_GETPATH\n            path_buf = b\"\\0\" * 1024\n            result = fcntl.fcntl(fd, f_getpath, path_buf)\n            return result.rstrip(b\"\\0\").decode(\"utf-8\")\n    except (OSError, ValueError):\n        return None\n    else:\n        # Windows doesn't have an easy way to get the path from an fd\n        return None\n\n\ndef wrap_function(\n    function: Callable[..., Any], function_path: str, arg_index: int, kwarg_key: str, root: str\n) -> Callable[..., Any]:\n    is_unlink = function == os.unlink\n\n    def wrapper(*args: Any, **kwargs: Any) -> Any:\n        value = kwargs[kwarg_key] if kwarg_key in kwargs else args[arg_index]\n\n        # Allow tempfile.TemporaryFile's internal unlink calls to work.\n        if value in allowed_tempfile_internal_unlink_calls:\n            return function(*args, **kwargs)\n\n        # For unlink(), allow relative paths when dir_fd is provided (used by shutil.rmtree)\n        if is_unlink and \"dir_fd\" in kwargs and kwargs[\"dir_fd\"] is not None:\n            dir_fd = kwargs[\"dir_fd\"]\n            dir_path = get_path_from_fd(dir_fd)\n\n            if sys.platform in {\"linux\", \"darwin\"}:\n                msg = f\"Failed to resolve dir_fd to path for {function_path}()\"\n                assert dir_path is not None, msg\n\n                msg = f\"The dir_fd argument to {function_path}() must point to a directory rooted in {root}\"\n                assert dir_path[: len(str(root))] == str(root), msg\n            # On Windows, we can't easily validate dir_fd, but it's reasonably safe because the dir_fd typically comes\n            # from opening a path we already validated (e.g., from shutil.rmtree), and this is test infrastructure with\n            # trusted (non-adversarial) code.\n\n            return function(*args, **kwargs)\n\n        msg = f\"The '{kwarg_key}' argument to {function_path}() must be an absolute path\"\n        assert value == os.path.abspath(value), msg\n\n        msg = f\"The '{kwarg_key}' argument to {function_path}() must be rooted in {root}\"\n        assert value[: len(str(root))] == str(root), msg\n\n        return function(*args, **kwargs)\n\n    return wrapper\n\n\ndef wrap_open(root: str) -> Callable[..., Any]:\n    wrapped = builtins.open\n\n    def wrapper(*args: Any, **kwargs: Any) -> Any:\n        value = kwargs[\"file\"] if \"file\" in kwargs else args[0]\n\n        mode = \"r\"\n        if \"mode\" in kwargs:\n            mode = kwargs[\"mode\"]\n        elif len(args) >= 2:\n            mode = args[1]\n\n        msg = \"The 'file' argument to open() must be an absolute path\"\n        if value != os.devnull and \"w\" in mode:\n            assert value == os.path.abspath(value), msg\n\n        msg = f\"The 'file' argument to open() must be rooted in {root}\"\n        if value != os.devnull and \"w\" in mode:\n            assert value[: len(str(root))] == str(root), msg\n\n        return wrapped(*args, **kwargs)\n\n    return wrapper\n\n\ndef rmtree_error_handler(_function: Any, path: str, _excinfo: Any) -> None:\n    # Handle read-only files and directories.\n    os.chmod(path, 0o777)\n    if os.path.isdir(path):\n        rmtree(path)\n    else:\n        os.unlink(path)\n\n\n@pytest.fixture(autouse=True, scope=\"session\")\ndef standardize_tmp() -> None:\n    r\"\"\"Standardize the temporary directory path.\n\n    On MacOS, `/var` is a symlink to `/private/var`.\n    This creates issues with link canonicalization and relative link tests,\n    so this fixture rewrites environment variables and Python variables\n    to ensure the tests work the same as on Linux and Windows.\n\n    On Windows in GitHub CI, the temporary directory may be a short path.\n    For example, `C:\\Users\\RUNNER~1\\...` instead of `C:\\Users\\runneradmin\\...`.\n    This causes string-based path comparisons to fail.\n    \"\"\"\n\n    tmp = tempfile.gettempdir()\n    # MacOS: `/var` is a symlink.\n    tmp = os.path.abspath(os.path.realpath(tmp))\n    # Windows: The temporary directory may be a short path.\n    if sys.platform == \"win32\":\n        tmp = get_long_path(tmp)\n    os.environ[\"TMP\"] = tmp\n    os.environ[\"TEMP\"] = tmp\n    os.environ[\"TMPDIR\"] = tmp\n    tempfile.tempdir = tmp\n\n\n@pytest.fixture(autouse=True)\ndef root(standardize_tmp: None) -> Generator[str, None, None]:\n    _ = standardize_tmp\n    \"\"\"Create a temporary directory for the duration of each test.\"\"\"\n\n    # Reset allowed_tempfile_internal_unlink_calls.\n    global allowed_tempfile_internal_unlink_calls  # noqa: PLW0603\n    allowed_tempfile_internal_unlink_calls = []\n\n    # Dotbot changes the current working directory,\n    # so this must be reset at the end of each test.\n    current_working_directory = os.getcwd()\n\n    # Create an isolated temporary directory from which to operate.\n    current_root = tempfile.mkdtemp()\n\n    functions_to_wrap = [\n        (os, \"chflags\", 0, \"path\"),\n        (os, \"chmod\", 0, \"path\"),\n        (os, \"chown\", 0, \"path\"),\n        (os, \"lchflags\", 0, \"path\"),\n        (os, \"lchmod\", 0, \"path\"),\n        (os, \"link\", 1, \"dst\"),\n        (os, \"makedirs\", 0, \"name\"),\n        (os, \"mkdir\", 0, \"path\"),\n        (os, \"mkfifo\", 0, \"path\"),\n        (os, \"mknod\", 0, \"path\"),\n        (os, \"remove\", 0, \"path\"),\n        (os, \"removedirs\", 0, \"name\"),\n        (os, \"removexattr\", 0, \"path\"),\n        (os, \"rename\", 0, \"src\"),  # Check both\n        (os, \"rename\", 1, \"dst\"),\n        (os, \"renames\", 0, \"old\"),  # Check both\n        (os, \"renames\", 1, \"new\"),\n        (os, \"replace\", 0, \"src\"),  # Check both\n        (os, \"replace\", 1, \"dst\"),\n        (os, \"rmdir\", 0, \"path\"),\n        (os, \"setxattr\", 0, \"path\"),\n        (os, \"splice\", 1, \"dst\"),\n        (os, \"symlink\", 1, \"dst\"),\n        (os, \"truncate\", 0, \"path\"),\n        (os, \"unlink\", 0, \"path\"),\n        (os, \"utime\", 0, \"path\"),\n        (shutil, \"chown\", 0, \"path\"),\n        (shutil, \"copy\", 1, \"dst\"),\n        (shutil, \"copy2\", 1, \"dst\"),\n        (shutil, \"copyfile\", 1, \"dst\"),\n        (shutil, \"copymode\", 1, \"dst\"),\n        (shutil, \"copystat\", 1, \"dst\"),\n        (shutil, \"copytree\", 1, \"dst\"),\n        (shutil, \"make_archive\", 0, \"base_name\"),\n        (shutil, \"move\", 0, \"src\"),  # Check both\n        (shutil, \"move\", 1, \"dst\"),\n        (shutil, \"rmtree\", 0, \"path\"),\n        (shutil, \"unpack_archive\", 1, \"extract_dir\"),\n    ]\n\n    patches: List[Any] = []\n    for module, function_name, arg_index, kwarg_key in functions_to_wrap:\n        # Skip anything that doesn't exist in this version of Python.\n        if not hasattr(module, function_name):\n            continue\n\n        # These values must be passed to a separate function\n        # to ensure the variable closures work correctly.\n        function_path = f\"{module.__name__}.{function_name}\"\n        function = getattr(module, function_name)\n        wrapped = wrap_function(function, function_path, arg_index, kwarg_key, current_root)\n        patches.append(mock.patch(function_path, wrapped))\n\n    # open() must be separately wrapped.\n    function_path = \"builtins.open\"\n    wrapped = wrap_open(current_root)\n    patches.append(mock.patch(function_path, wrapped))\n\n    # Block all access to bad functions.\n    if hasattr(os, \"chroot\"):\n        patches.append(mock.patch(\"os.chroot\", return_value=None))\n\n    # Patch tempfile._mkstemp_inner() so tempfile.TemporaryFile()\n    # can unlink files immediately.\n    mkstemp_inner = tempfile._mkstemp_inner  # type: ignore # noqa: SLF001\n\n    def wrap_mkstemp_inner(*args: Any, **kwargs: Any) -> Any:\n        (fd, name) = mkstemp_inner(*args, **kwargs)\n        allowed_tempfile_internal_unlink_calls.append(name)\n        return fd, name\n\n    patches.append(mock.patch(\"tempfile._mkstemp_inner\", wrap_mkstemp_inner))\n\n    [patch.start() for patch in patches]\n    try:\n        yield current_root\n    finally:\n        # Patches must be stopped in reverse order because some patches are nested.\n        # Stopping in the reverse order restores the original function.\n        for patch in reversed(patches):\n            patch.stop()\n        os.chdir(current_working_directory)\n        if sys.version_info >= (3, 12):\n            rmtree(current_root, onexc=rmtree_error_handler)\n        else:\n            rmtree(current_root, onerror=rmtree_error_handler)\n\n\n@pytest.fixture\ndef home(monkeypatch: pytest.MonkeyPatch, root: str) -> str:\n    \"\"\"Create a home directory for the duration of the test.\n\n    On *nix, the environment variable \"HOME\" will be mocked.\n    On Windows, the environment variable \"USERPROFILE\" will be mocked.\n    \"\"\"\n\n    home = os.path.abspath(os.path.join(root, \"home/user\"))\n    os.makedirs(home)\n    if sys.platform == \"win32\":\n        monkeypatch.setenv(\"USERPROFILE\", home)\n    else:\n        monkeypatch.setenv(\"HOME\", home)\n    return home\n\n\nclass Dotfiles:\n    \"\"\"Create and manage a dotfiles directory for a test.\"\"\"\n\n    def __init__(self, root: str):\n        self.root = root\n        self.config = None\n        self._config_filename: Optional[str] = None\n        self.directory = os.path.join(root, \"dotfiles\")\n        os.mkdir(self.directory)\n\n    def makedirs(self, path: str) -> None:\n        os.makedirs(os.path.abspath(os.path.join(self.directory, path)))\n\n    def write(self, path: str, content: str = \"\") -> None:\n        path = os.path.abspath(os.path.join(self.directory, path))\n        if not os.path.exists(os.path.dirname(path)):\n            os.makedirs(os.path.dirname(path))\n        with open(path, \"w\") as file:\n            file.write(content)\n\n    def write_config(self, config: Any, serializer: str = \"yaml\", path: Optional[str] = None) -> str:\n        \"\"\"Write a dotbot config and return the filename.\"\"\"\n\n        assert serializer in {\"json\", \"yaml\"}, \"Only json and yaml are supported\"\n        if serializer == \"yaml\":\n            serialize: Callable[[Any], str] = yaml.dump\n        else:  # serializer == \"json\"\n            serialize = json.dumps\n\n        if path is not None:\n            msg = \"The config file path must be an absolute path\"\n            assert path == os.path.abspath(path), msg\n\n            msg = f\"The config file path must be rooted in {root}\"\n            assert path[: len(str(root))] == str(root), msg\n\n            self._config_filename = path\n        else:\n            self._config_filename = os.path.join(self.directory, \"install.conf.yaml\")\n        self.config = config\n\n        with open(self._config_filename, \"w\") as file:\n            file.write(serialize(config))\n        return self._config_filename\n\n    @property\n    def config_filename(self) -> str:\n        assert self._config_filename is not None\n        return self._config_filename\n\n\n@pytest.fixture\ndef dotfiles(root: str) -> Dotfiles:\n    \"\"\"Create a dotfiles directory.\"\"\"\n\n    return Dotfiles(root)\n\n\n@pytest.fixture\ndef run_dotbot(dotfiles: Dotfiles) -> Callable[..., None]:\n    \"\"\"Run dotbot.\n\n    When calling `runner()`, only CLI arguments need to be specified.\n\n    If the keyword-only argument *custom* is True\n    then the CLI arguments will not be modified,\n    and the caller will be responsible for all CLI arguments.\n    \"\"\"\n\n    def runner(*argv: Any, **kwargs: Any) -> None:\n        argv = (\"dotbot\", *argv)\n        if kwargs.get(\"custom\", False) is not True:\n            argv = (*argv, \"-c\", dotfiles.config_filename)\n        with mock.patch(\"sys.argv\", list(argv)):\n            dotbot.cli.main()\n\n    return runner\n"
  },
  {
    "path": "tests/dotbot_plugin_context_plugin.py",
    "content": "# https://github.com/anishathalye/dotbot/issues/339\n# plugins should be able to instantiate a Dispatcher with all the plugins\n\nfrom typing import Any\n\nimport dotbot\nfrom dotbot.dispatcher import Dispatcher\n\n\nclass Dispatch(dotbot.Plugin):\n    def can_handle(self, directive: str) -> bool:\n        return directive == \"dispatch\"\n\n    def handle(self, directive: str, data: Any) -> bool:\n        if directive != \"dispatch\":\n            msg = f\"Dispatch cannot handle directive {directive}\"\n            raise ValueError(msg)\n        dispatcher = Dispatcher(\n            base_directory=self._context.base_directory(),\n            options=self._context.options(),\n            plugins=self._context.plugins(),\n        )\n        return dispatcher.dispatch(data)\n"
  },
  {
    "path": "tests/dotbot_plugin_counter.py",
    "content": "\"\"\"Test plugin that counts how many times it's executed.\n\nThis file is used to test that duplicate plugin references\ndon't cause the plugin to be loaded/executed multiple times.\n\"\"\"\n\nimport os.path\nfrom typing import Any\n\nimport dotbot\n\n\nclass Counter(dotbot.Plugin):\n    def can_handle(self, directive: str) -> bool:\n        return directive == \"counter\"\n\n    def handle(self, directive: str, _data: Any) -> bool:\n        if directive != \"counter\":\n            msg = f\"Counter cannot handle directive {directive}\"\n            raise ValueError(msg)\n\n        counter_file = os.path.abspath(os.path.expanduser(\"~/counter\"))\n        if os.path.exists(counter_file):\n            with open(counter_file) as f:\n                count = int(f.read().strip())\n        else:\n            count = 0\n        count += 1\n        with open(counter_file, \"w\") as f:\n            f.write(str(count))\n        return True\n"
  },
  {
    "path": "tests/dotbot_plugin_directory.py",
    "content": "\"\"\"Test that a plugin can be loaded by directory.\n\nThis file is copied to a location with the name \"directory.py\",\nand is then loaded from within the `test_cli.py` code.\n\"\"\"\n\nimport os.path\nfrom typing import Any\n\nimport dotbot\n\n\nclass Directory(dotbot.Plugin):\n    def can_handle(self, directive: str) -> bool:\n        return directive == \"plugin_directory\"\n\n    def handle(self, directive: str, data: Any) -> bool:\n        if directive != \"plugin_directory\":\n            msg = f\"Directory cannot handle directive {directive}\"\n            raise ValueError(msg)\n\n        if data != \"no-check-context\":\n            self._log.debug(\"Attempting to get options from Context\")\n            options = self._context.options()\n            if len(options.plugin_dirs) != 1:\n                self._log.debug(f\"Context.options.plugin_dirs length is {len(options.plugin_dirs)}, expected 1\")\n                return False\n\n        with open(os.path.abspath(os.path.expanduser(\"~/flag-directory\")), \"w\") as file:\n            file.write(\"directory plugin loading works\")\n        return True\n"
  },
  {
    "path": "tests/dotbot_plugin_dispatcher_no_plugins.py",
    "content": "# https://github.com/anishathalye/dotbot/issues/339, https://github.com/anishathalye/dotbot/pull/332\n# if plugins instantiate a Dispatcher without explicitly passing in plugins,\n# the Dispatcher should have access to all plugins (matching context.plugins())\n\nfrom typing import Any\n\nimport dotbot\nfrom dotbot.dispatcher import Dispatcher\n\n\nclass Dispatch(dotbot.Plugin):\n    def can_handle(self, directive: str) -> bool:\n        return directive == \"dispatch\"\n\n    def handle(self, directive: str, data: Any) -> bool:\n        if directive != \"dispatch\":\n            msg = f\"Dispatch cannot handle directive {directive}\"\n            raise ValueError(msg)\n        dispatcher = Dispatcher(\n            base_directory=self._context.base_directory(),\n            options=self._context.options(),\n        )\n        return dispatcher.dispatch(data)\n"
  },
  {
    "path": "tests/dotbot_plugin_dry_run.py",
    "content": "import os\nfrom typing import Any\n\nimport dotbot\n\n\nclass DryRun(dotbot.Plugin):\n    \"\"\"A plugin that is aware of dry-run mode.\"\"\"\n\n    _directive = \"dry_run\"\n    supports_dry_run = True\n\n    def can_handle(self, directive: str) -> bool:\n        return directive == self._directive\n\n    def handle(self, _directive: str, _data: Any) -> bool:\n        if self._context.dry_run():\n            self._log.action(\"Would execute dry run\")\n        else:\n            with open(os.path.abspath(os.path.expanduser(\"~/flag-dry-run\")), \"w\") as file:\n                file.write(\"Dry run executed\")\n        return True\n"
  },
  {
    "path": "tests/dotbot_plugin_file.py",
    "content": "\"\"\"Test that a plugin can be loaded by filename.\n\nThis file is copied to a location with the name \"file.py\",\nand is then loaded from within the `test_cli.py` code.\n\"\"\"\n\nimport os.path\nfrom typing import Any\n\nimport dotbot\n\n\nclass File(dotbot.Plugin):\n    def can_handle(self, directive: str) -> bool:\n        return directive == \"plugin_file\"\n\n    def handle(self, directive: str, data: Any) -> bool:\n        if directive != \"plugin_file\":\n            msg = f\"File cannot handle directive {directive}\"\n            raise ValueError(msg)\n\n        if data != \"no-check-context\":\n            self._log.debug(\"Attempting to get options from Context\")\n            options = self._context.options()\n            if len(options.plugins) != 1:\n                self._log.debug(f\"Context.options.plugins length is {len(options.plugins)}, expected 1\")\n                return False\n            if not options.plugins[0].endswith(\"file.py\"):\n                self._log.debug(f\"Context.options.plugins[0] is {options.plugins[0]}, expected end with file.py\")\n                return False\n\n        with open(os.path.abspath(os.path.expanduser(\"~/flag-file\")), \"w\") as file:\n            file.write(\"file plugin loading works\")\n        return True\n"
  },
  {
    "path": "tests/dotbot_plugin_issue_357.py",
    "content": "from typing import Any\n\nfrom dotbot.plugin import Plugin\nfrom dotbot.plugins import Clean, Create, Link, Shell\n\n# https://github.com/anishathalye/dotbot/issues/357\n# if we import from dotbot.plugins, the built-in plugins get executed multiple times\n_: Any = Clean\n_ = Create\n_ = Link\n_ = Shell\n\n\nclass NoopPlugin(Plugin):\n    _directive = \"noop\"\n\n    def can_handle(self, directive: str) -> bool:\n        return directive == self._directive\n\n    def handle(self, directive: str, _data: Any) -> bool:\n        if directive != self._directive:\n            msg = f\"NoopPlugin cannot handle directive {directive}\"\n            raise ValueError(msg)\n        return True\n"
  },
  {
    "path": "tests/test_bin_dotbot.py",
    "content": "import os\nimport shutil\nimport subprocess\nfrom typing import Optional\n\nimport pytest\n\nfrom tests.conftest import Dotfiles\n\n\n@pytest.mark.skipif(\n    \"sys.platform == 'win32'\",\n    reason=\"The hybrid sh/Python dotbot script doesn't run on Windows platforms\",\n)\n@pytest.mark.parametrize(\"python_name\", [None, \"python\", \"python3\"])\ndef test_find_python_executable(python_name: Optional[str], home: str, dotfiles: Dotfiles) -> None:\n    \"\"\"Verify that the sh/Python hybrid dotbot executable can find Python.\"\"\"\n\n    dotfiles.write_config([])\n    dotbot_executable = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), \"bin\", \"dotbot\")\n\n    # Create a link to sh.\n    tmp_bin = os.path.join(home, \"tmp_bin\")\n    os.makedirs(tmp_bin)\n    sh_path = shutil.which(\"sh\")\n    assert sh_path is not None\n    os.symlink(sh_path, os.path.join(tmp_bin, \"sh\"))\n\n    if python_name is not None:\n        with open(os.path.join(tmp_bin, python_name), \"w\") as file:\n            file.write(\"#!\" + tmp_bin + \"/sh\\n\")\n            file.write(\"exit 0\\n\")\n        os.chmod(os.path.join(tmp_bin, python_name), 0o777)\n    env = dict(os.environ)\n    env[\"PATH\"] = tmp_bin\n\n    if python_name is not None:\n        subprocess.check_call(\n            [dotbot_executable, \"-c\", dotfiles.config_filename],\n            env=env,\n        )\n    else:\n        with pytest.raises(subprocess.CalledProcessError):\n            subprocess.check_call(\n                [dotbot_executable, \"-c\", dotfiles.config_filename],\n                env=env,\n            )\n"
  },
  {
    "path": "tests/test_clean.py",
    "content": "import os\nimport sys\nfrom typing import Callable\n\nimport pytest\n\nfrom tests.conftest import Dotfiles\n\n\ndef test_clean_default(root: str, home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:\n    \"\"\"Verify clean uses default unless overridden.\"\"\"\n\n    os.symlink(os.path.join(root, \"nowhere\"), os.path.join(home, \".g\"))\n    dotfiles.write_config(\n        [\n            {\n                \"clean\": {\n                    \"~/nonexistent\": {\"force\": True},\n                    \"~/\": None,\n                },\n            }\n        ]\n    )\n    run_dotbot()\n\n    assert not os.path.isdir(os.path.join(home, \"nonexistent\"))\n    assert os.path.islink(os.path.join(home, \".g\"))\n\n\ndef test_clean_environment_variable_expansion(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:\n    \"\"\"Verify clean expands environment variables.\"\"\"\n\n    os.symlink(os.path.join(dotfiles.directory, \"f\"), os.path.join(home, \".f\"))\n    variable = \"$HOME\"\n    if sys.platform == \"win32\":\n        variable = \"$USERPROFILE\"\n    dotfiles.write_config([{\"clean\": [variable]}])\n    run_dotbot()\n\n    assert not os.path.islink(os.path.join(home, \".f\"))\n\n\ndef test_clean_missing(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:\n    \"\"\"Verify clean deletes links to missing files.\"\"\"\n\n    dotfiles.write(\"f\")\n    os.symlink(os.path.join(dotfiles.directory, \"f\"), os.path.join(home, \".f\"))\n    os.symlink(os.path.join(dotfiles.directory, \"g\"), os.path.join(home, \".g\"))\n    dotfiles.write_config([{\"clean\": [\"~\"]}])\n    run_dotbot()\n\n    assert os.path.islink(os.path.join(home, \".f\"))\n    assert not os.path.islink(os.path.join(home, \".g\"))\n\n\ndef test_clean_nonexistent(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:\n    \"\"\"Verify clean ignores nonexistent directories.\"\"\"\n\n    dotfiles.write_config([{\"clean\": [\"~\", \"~/fake\"]}])\n    run_dotbot()  # Nonexistent directories should not raise exceptions.\n\n    assert not os.path.isdir(os.path.join(home, \"fake\"))\n\n\ndef test_clean_outside_force(root: str, home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:\n    \"\"\"Verify clean forced to remove files linking outside dotfiles directory.\"\"\"\n\n    os.symlink(os.path.join(root, \"nowhere\"), os.path.join(home, \".g\"))\n    dotfiles.write_config([{\"clean\": {\"~/\": {\"force\": True}}}])\n    run_dotbot()\n\n    assert not os.path.islink(os.path.join(home, \".g\"))\n\n\ndef test_clean_outside(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:\n    \"\"\"Verify clean ignores files linking outside dotfiles directory.\"\"\"\n\n    os.symlink(os.path.join(dotfiles.directory, \"f\"), os.path.join(home, \".f\"))\n    os.symlink(os.path.join(home, \"g\"), os.path.join(home, \".g\"))\n    dotfiles.write_config([{\"clean\": [\"~\"]}])\n    run_dotbot()\n\n    assert not os.path.islink(os.path.join(home, \".f\"))\n    assert os.path.islink(os.path.join(home, \".g\"))\n\n\ndef test_clean_recursive_1(root: str, home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:\n    \"\"\"Verify clean respects when the recursive directive is off (default).\"\"\"\n\n    os.makedirs(os.path.join(home, \"a\", \"b\"))\n    os.symlink(os.path.join(root, \"nowhere\"), os.path.join(home, \"c\"))\n    os.symlink(os.path.join(root, \"nowhere\"), os.path.join(home, \"a\", \"d\"))\n    os.symlink(os.path.join(root, \"nowhere\"), os.path.join(home, \"a\", \"b\", \"e\"))\n    dotfiles.write_config([{\"clean\": {\"~\": {\"force\": True}}}])\n    run_dotbot()\n\n    assert not os.path.islink(os.path.join(home, \"c\"))\n    assert os.path.islink(os.path.join(home, \"a\", \"d\"))\n    assert os.path.islink(os.path.join(home, \"a\", \"b\", \"e\"))\n\n\ndef test_clean_recursive_2(root: str, home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:\n    \"\"\"Verify clean respects when the recursive directive is on.\"\"\"\n\n    os.makedirs(os.path.join(home, \"a\", \"b\"))\n    os.symlink(os.path.join(root, \"nowhere\"), os.path.join(home, \"c\"))\n    os.symlink(os.path.join(root, \"nowhere\"), os.path.join(home, \"a\", \"d\"))\n    os.symlink(os.path.join(root, \"nowhere\"), os.path.join(home, \"a\", \"b\", \"e\"))\n    dotfiles.write_config([{\"clean\": {\"~\": {\"force\": True, \"recursive\": True}}}])\n    run_dotbot()\n\n    assert not os.path.islink(os.path.join(home, \"c\"))\n    assert not os.path.islink(os.path.join(home, \"a\", \"d\"))\n    assert not os.path.islink(os.path.join(home, \"a\", \"b\", \"e\"))\n\n\ndef test_clean_defaults_1(root: str, home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:\n    \"\"\"Verify that clean doesn't erase non-dotfiles links by default.\"\"\"\n\n    os.symlink(os.path.join(root, \"nowhere\"), os.path.join(home, \".g\"))\n    dotfiles.write_config([{\"clean\": [\"~\"]}])\n    run_dotbot()\n\n    assert os.path.islink(os.path.join(home, \".g\"))\n\n\ndef test_clean_defaults_2(root: str, home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:\n    \"\"\"Verify that explicit clean defaults override the implicit default.\"\"\"\n\n    os.symlink(os.path.join(root, \"nowhere\"), os.path.join(home, \".g\"))\n    dotfiles.write_config(\n        [\n            {\"defaults\": {\"clean\": {\"force\": True}}},\n            {\"clean\": [\"~\"]},\n        ]\n    )\n    run_dotbot()\n\n    assert not os.path.islink(os.path.join(home, \".g\"))\n\n\ndef test_clean_dry_run(\n    capfd: pytest.CaptureFixture[str], root: str, home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]\n) -> None:\n    \"\"\"Verify that the clean plugin does not delete files during a dry run.\"\"\"\n\n    os.symlink(os.path.join(root, \"nowhere\"), os.path.join(home, \".g\"))\n    dotfiles.write_config([{\"clean\": {\"~/\": {\"force\": True}}}])\n    run_dotbot(\"-n\")\n\n    assert os.path.islink(os.path.join(home, \".g\"))\n\n    lines = capfd.readouterr().out.splitlines()\n    assert any(\n        f\"Would remove invalid link {os.path.join(home, '.g')} -> {os.path.join(root, 'nowhere')}\" in line\n        for line in lines\n    )\n\n\ndef test_clean_dry_run_recursive(\n    capfd: pytest.CaptureFixture[str], root: str, home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]\n) -> None:\n    \"\"\"Verify that the clean plugin does not delete files during a recursive dry run.\"\"\"\n\n    os.makedirs(os.path.join(home, \"a\", \"b\"))\n    os.symlink(os.path.join(root, \"nowhere\"), os.path.join(home, \"c\"))\n    os.symlink(os.path.join(root, \"nowhere\"), os.path.join(home, \"a\", \"d\"))\n    os.symlink(os.path.join(root, \"nowhere\"), os.path.join(home, \"a\", \"b\", \"e\"))\n    dotfiles.write_config([{\"clean\": {\"~\": {\"force\": True, \"recursive\": True}}}])\n    run_dotbot(\"-n\")\n\n    assert os.path.islink(os.path.join(home, \"c\"))\n    assert os.path.islink(os.path.join(home, \"a\", \"d\"))\n    assert os.path.islink(os.path.join(home, \"a\", \"b\", \"e\"))\n\n    lines = capfd.readouterr().out.splitlines()\n    assert any(\n        f\"Would remove invalid link {os.path.join(home, 'c')} -> {os.path.join(root, 'nowhere')}\" in line\n        for line in lines\n    )\n    assert any(\n        f\"Would remove invalid link {os.path.join(home, 'a', 'd')} -> {os.path.join(root, 'nowhere')}\" in line\n        for line in lines\n    )\n    assert any(\n        f\"Would remove invalid link {os.path.join(home, 'a', 'b', 'e')} -> {os.path.join(root, 'nowhere')}\" in line\n        for line in lines\n    )\n"
  },
  {
    "path": "tests/test_cli.py",
    "content": "import os\nimport shutil\nfrom typing import Callable\n\nimport pytest\n\nfrom tests.conftest import Dotfiles\n\n\ndef test_except_create(\n    capfd: pytest.CaptureFixture[str], home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]\n) -> None:\n    \"\"\"Verify that `--except` works as intended.\"\"\"\n\n    dotfiles.write_config(\n        [\n            {\"create\": [\"~/a\"]},\n            {\n                \"shell\": [\n                    {\"command\": \"echo success\", \"stdout\": True},\n                ]\n            },\n        ]\n    )\n    run_dotbot(\"--except\", \"create\")\n\n    assert not os.path.exists(os.path.join(home, \"a\"))\n    stdout = capfd.readouterr().out.splitlines()\n    assert any(line.startswith(\"success\") for line in stdout)\n\n\ndef test_except_shell(\n    capfd: pytest.CaptureFixture[str], home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]\n) -> None:\n    \"\"\"Verify that `--except` works as intended.\"\"\"\n\n    dotfiles.write_config(\n        [\n            {\"create\": [\"~/a\"]},\n            {\n                \"shell\": [\n                    {\"command\": \"echo failure\", \"stdout\": True},\n                ]\n            },\n        ]\n    )\n    run_dotbot(\"--except\", \"shell\")\n\n    assert os.path.exists(os.path.join(home, \"a\"))\n    stdout = capfd.readouterr().out.splitlines()\n    assert not any(line.startswith(\"failure\") for line in stdout)\n\n\ndef test_except_multiples(\n    capfd: pytest.CaptureFixture[str], home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]\n) -> None:\n    \"\"\"Verify that `--except` works with multiple exceptions.\"\"\"\n\n    dotfiles.write_config(\n        [\n            {\"create\": [\"~/a\"]},\n            {\n                \"shell\": [\n                    {\"command\": \"echo failure\", \"stdout\": True},\n                ]\n            },\n        ]\n    )\n    run_dotbot(\"--except\", \"create\", \"shell\")\n\n    assert not os.path.exists(os.path.join(home, \"a\"))\n    stdout = capfd.readouterr().out.splitlines()\n    assert not any(line.startswith(\"failure\") for line in stdout)\n\n\ndef test_exit_on_failure(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:\n    \"\"\"Verify that processing can halt immediately on failures.\"\"\"\n\n    dotfiles.write_config(\n        [\n            {\"create\": [\"~/a\"]},\n            {\"shell\": [\"this_is_not_a_command\"]},\n            {\"create\": [\"~/b\"]},\n        ]\n    )\n    with pytest.raises(SystemExit):\n        run_dotbot(\"-x\")\n\n    assert os.path.isdir(os.path.join(home, \"a\"))\n    assert not os.path.isdir(os.path.join(home, \"b\"))\n\n\ndef test_only(\n    capfd: pytest.CaptureFixture[str], home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]\n) -> None:\n    \"\"\"Verify that `--only` works as intended.\"\"\"\n\n    dotfiles.write_config(\n        [\n            {\"create\": [\"~/a\"]},\n            {\"shell\": [{\"command\": \"echo success\", \"stdout\": True}]},\n        ]\n    )\n    run_dotbot(\"--only\", \"shell\")\n\n    assert not os.path.exists(os.path.join(home, \"a\"))\n    stdout = capfd.readouterr().out.splitlines()\n    assert any(line.startswith(\"success\") for line in stdout)\n\n\ndef test_only_with_defaults(\n    capfd: pytest.CaptureFixture[str], home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]\n) -> None:\n    \"\"\"Verify that `--only` does not suppress defaults.\"\"\"\n\n    dotfiles.write_config(\n        [\n            {\"defaults\": {\"shell\": {\"stdout\": True}}},\n            {\"create\": [\"~/a\"]},\n            {\"shell\": [{\"command\": \"echo success\"}]},\n        ]\n    )\n    run_dotbot(\"--only\", \"shell\")\n\n    assert not os.path.exists(os.path.join(home, \"a\"))\n    stdout = capfd.readouterr().out.splitlines()\n    assert any(line.startswith(\"success\") for line in stdout)\n\n\ndef test_only_with_multiples(\n    capfd: pytest.CaptureFixture[str], home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]\n) -> None:\n    \"\"\"Verify that `--only` works as intended.\"\"\"\n\n    dotfiles.write_config(\n        [\n            {\"create\": [\"~/a\"]},\n            {\"shell\": [{\"command\": \"echo success\", \"stdout\": True}]},\n            {\"link\": [\"~/.f\"]},\n        ]\n    )\n    run_dotbot(\"--only\", \"create\", \"shell\")\n\n    assert os.path.isdir(os.path.join(home, \"a\"))\n    stdout = capfd.readouterr().out.splitlines()\n    assert any(line.startswith(\"success\") for line in stdout)\n    assert not os.path.exists(os.path.join(home, \".f\"))\n\n\ndef test_plugin_loading_file(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:\n    \"\"\"Verify that plugins can be loaded by file.\"\"\"\n\n    plugin_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), \"dotbot_plugin_file.py\")\n    shutil.copy(plugin_file, os.path.join(dotfiles.directory, \"file.py\"))\n    dotfiles.write_config([{\"plugin_file\": \"~\"}])\n    run_dotbot(\"--plugin\", os.path.join(dotfiles.directory, \"file.py\"))\n\n    with open(os.path.join(home, \"flag-file\")) as file:\n        assert file.read() == \"file plugin loading works\"\n\n\ndef test_plugin_loading_directory(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:\n    \"\"\"Verify that plugins can be loaded from a directory.\"\"\"\n\n    dotfiles.makedirs(\"plugins\")\n    plugin_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), \"dotbot_plugin_directory.py\")\n    shutil.copy(plugin_file, os.path.join(dotfiles.directory, \"plugins\", \"directory.py\"))\n    dotfiles.write_config([{\"plugin_directory\": \"~\"}])\n    run_dotbot(\"--plugin-dir\", os.path.join(dotfiles.directory, \"plugins\"))\n\n    with open(os.path.join(home, \"flag-directory\")) as file:\n        assert file.read() == \"directory plugin loading works\"\n\n\ndef test_issue_357(\n    capfd: pytest.CaptureFixture[str], home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]\n) -> None:\n    \"\"\"Verify that built-in plugins are only executed once, when\n    using a plugin that imports from dotbot.plugins.\"\"\"\n\n    _ = home\n    plugin_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), \"dotbot_plugin_issue_357.py\")\n    dotfiles.write_config([{\"shell\": [{\"command\": \"echo apple\", \"stdout\": True}]}])\n\n    run_dotbot(\"--plugin\", plugin_file)\n\n    assert len([line for line in capfd.readouterr().out.splitlines() if line.strip() == \"apple\"]) == 1\n\n\ndef test_disable_builtin_plugins(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:\n    \"\"\"Verify that builtin plugins can be disabled.\"\"\"\n\n    dotfiles.write(\"f\", \"apple\")\n    dotfiles.write_config([{\"link\": {\"~/.f\": \"f\"}}])\n\n    # The link directive will be unhandled so dotbot will raise SystemExit.\n    with pytest.raises(SystemExit):\n        run_dotbot(\"--disable-built-in-plugins\")\n\n    assert not os.path.exists(os.path.join(home, \".f\"))\n\n\ndef test_plugin_context_plugin(\n    capfd: pytest.CaptureFixture[str], home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]\n) -> None:\n    \"\"\"Verify that the plugin context is available to plugins.\"\"\"\n\n    _ = home\n    plugin_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), \"dotbot_plugin_context_plugin.py\")\n    shutil.copy(plugin_file, os.path.join(dotfiles.directory, \"plugin.py\"))\n    dotfiles.write_config([{\"dispatch\": [{\"shell\": [{\"command\": \"echo apple\", \"stdout\": True}]}]}])\n    run_dotbot(\"--plugin\", os.path.join(dotfiles.directory, \"plugin.py\"))\n\n    stdout = capfd.readouterr().out.splitlines()\n    assert any(line.startswith(\"apple\") for line in stdout)\n\n\ndef test_plugin_dispatcher_no_plugins(\n    capfd: pytest.CaptureFixture[str], home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]\n) -> None:\n    \"\"\"Verify that plugins instantiating Dispatcher without plugins work.\"\"\"\n\n    _ = home\n    plugin_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), \"dotbot_plugin_dispatcher_no_plugins.py\")\n    shutil.copy(plugin_file, os.path.join(dotfiles.directory, \"plugin.py\"))\n    dotfiles.write_config([{\"dispatch\": [{\"shell\": [{\"command\": \"echo apple\", \"stdout\": True}]}]}])\n    run_dotbot(\"--plugin\", os.path.join(dotfiles.directory, \"plugin.py\"))\n\n    stdout = capfd.readouterr().out.splitlines()\n    assert any(line.startswith(\"apple\") for line in stdout)\n\n\ndef test_dry_run_unaware_plugin(\n    capfd: pytest.CaptureFixture[str], home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]\n) -> None:\n    \"\"\"Verify that plugins not aware of dry-run mode do not execute actions during a dry run.\"\"\"\n\n    plugin_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), \"dotbot_plugin_file.py\")\n    shutil.copy(plugin_file, os.path.join(dotfiles.directory, \"file.py\"))\n    dotfiles.write_config([{\"plugin_file\": \"~\"}])\n    run_dotbot(\"--plugin\", os.path.join(dotfiles.directory, \"file.py\"), \"--dry-run\")\n\n    assert not os.path.exists(os.path.join(home, \"flag-file\"))\n\n    stdout = capfd.readouterr().out.splitlines()\n    assert any(line.strip() == \"Skipping dry-run-unaware plugin File\" for line in stdout)\n\n\ndef test_dry_run_aware_plugin(\n    capfd: pytest.CaptureFixture[str], home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]\n) -> None:\n    \"\"\"Verify that plugins that are aware of dry-run mode do execute during a dry run.\"\"\"\n\n    plugin_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), \"dotbot_plugin_dry_run.py\")\n    shutil.copy(plugin_file, os.path.join(dotfiles.directory, \"dry_run.py\"))\n    dotfiles.write_config([{\"dry_run\": \"~\"}])\n    run_dotbot(\"--plugin\", os.path.join(dotfiles.directory, \"dry_run.py\"), \"--dry-run\")\n\n    assert not os.path.exists(os.path.join(home, \"flag-dry-run\"))\n    stdout = capfd.readouterr().out.splitlines()\n    assert any(line.startswith(\"Would execute dry run\") for line in stdout)\n\n\ndef test_dry_run_aware_plugin_no_dry_run(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:\n    \"\"\"Verify that plugins that are aware of dry-run mode do execute without dry run.\"\"\"\n\n    plugin_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), \"dotbot_plugin_dry_run.py\")\n    shutil.copy(plugin_file, os.path.join(dotfiles.directory, \"dry_run.py\"))\n    dotfiles.write_config([{\"dry_run\": \"~\"}])\n    run_dotbot(\"--plugin\", os.path.join(dotfiles.directory, \"dry_run.py\"))\n    with open(os.path.join(home, \"flag-dry-run\")) as file:\n        assert file.read() == \"Dry run executed\"\n"
  },
  {
    "path": "tests/test_config.py",
    "content": "import json\nimport os\nfrom typing import Callable\n\nfrom tests.conftest import Dotfiles\n\n\ndef test_config_blank(dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:\n    \"\"\"Verify blank configs work.\"\"\"\n\n    dotfiles.write_config([])\n    run_dotbot()\n\n\ndef test_config_empty(dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:\n    \"\"\"Verify empty configs work.\"\"\"\n\n    dotfiles.write(\"config.yaml\", \"\")\n    run_dotbot(\"-c\", os.path.join(dotfiles.directory, \"config.yaml\"), custom=True)\n\n\ndef test_json(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:\n    \"\"\"Verify JSON configs work.\"\"\"\n\n    document = json.dumps([{\"create\": [\"~/d\"]}])\n    dotfiles.write(\"config.json\", document)\n    run_dotbot(\"-c\", os.path.join(dotfiles.directory, \"config.json\"), custom=True)\n\n    assert os.path.isdir(os.path.join(home, \"d\"))\n\n\ndef test_json_tabs(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:\n    \"\"\"Verify JSON configs with tabs work.\"\"\"\n\n    document = \"\"\"[\\n\\t{\\n\\t\\t\"create\": [\"~/d\"]\\n\\t}\\n]\"\"\"\n    dotfiles.write(\"config.json\", document)\n    run_dotbot(\"-c\", os.path.join(dotfiles.directory, \"config.json\"), custom=True)\n\n    assert os.path.isdir(os.path.join(home, \"d\"))\n\n\ndef test_multiple_config(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:\n    \"\"\"Verify that passing multiple configs works.\"\"\"\n\n    dotfiles.write(\"config1.json\", json.dumps([{\"create\": [\"~/d1\"]}]))\n    dotfiles.write(\"config2.json\", json.dumps([{\"create\": [\"~/d2\"]}]))\n\n    run_dotbot(\n        \"-c\",\n        os.path.join(dotfiles.directory, \"config1.json\"),\n        os.path.join(dotfiles.directory, \"config2.json\"),\n        custom=True,\n    )\n\n    assert os.path.isdir(os.path.join(home, \"d1\"))\n    assert os.path.isdir(os.path.join(home, \"d2\"))\n"
  },
  {
    "path": "tests/test_create.py",
    "content": "import os\nimport stat\nfrom typing import Callable\n\nimport pytest\n\nfrom tests.conftest import Dotfiles\n\n\n@pytest.mark.parametrize(\"directory\", [\"~/a\", \"~/b/c\"])\ndef test_directory_creation(home: str, directory: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:\n    \"\"\"Test creating directories, including nested directories.\"\"\"\n\n    _ = home\n    dotfiles.write_config([{\"create\": [directory]}])\n    run_dotbot()\n\n    expanded_directory = os.path.abspath(os.path.expanduser(directory))\n    assert os.path.isdir(expanded_directory)\n    assert os.stat(expanded_directory).st_mode & 0o777 == 0o777\n\n\ndef test_default_mode(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:\n    \"\"\"Test creating a directory with an explicit default mode.\n\n    Note: `os.chmod()` on Windows only supports changing write permissions.\n    Therefore, this test is restricted to testing read-only access.\n    \"\"\"\n\n    _ = home\n    read_only = 0o777 - stat.S_IWUSR - stat.S_IWGRP - stat.S_IWOTH\n    config = [{\"defaults\": {\"create\": {\"mode\": read_only}}}, {\"create\": [\"~/a\"]}]\n    dotfiles.write_config(config)\n    run_dotbot()\n\n    directory = os.path.abspath(os.path.expanduser(\"~/a\"))\n    assert os.stat(directory).st_mode & stat.S_IWUSR == 0\n    assert os.stat(directory).st_mode & stat.S_IWGRP == 0\n    assert os.stat(directory).st_mode & stat.S_IWOTH == 0\n\n\ndef test_default_mode_override(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:\n    \"\"\"Test creating a directory that overrides an explicit default mode.\n\n    Note: `os.chmod()` on Windows only supports changing write permissions.\n    Therefore, this test is restricted to testing read-only access.\n    \"\"\"\n\n    _ = home\n    read_only = 0o777 - stat.S_IWUSR - stat.S_IWGRP - stat.S_IWOTH\n    config = [\n        {\"defaults\": {\"create\": {\"mode\": read_only}}},\n        {\"create\": {\"~/a\": {\"mode\": 0o777}}},\n    ]\n    dotfiles.write_config(config)\n    run_dotbot()\n\n    directory = os.path.abspath(os.path.expanduser(\"~/a\"))\n    assert os.stat(directory).st_mode & stat.S_IWUSR == stat.S_IWUSR\n    assert os.stat(directory).st_mode & stat.S_IWGRP == stat.S_IWGRP\n    assert os.stat(directory).st_mode & stat.S_IWOTH == stat.S_IWOTH\n\n\ndef test_create_dry_run(\n    capfd: pytest.CaptureFixture[str], home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]\n) -> None:\n    \"\"\"Verify that the create plugin does not create directories during a dry run.\"\"\"\n\n    os.makedirs(os.path.join(home, \"existing\"))\n    dotfiles.write_config([{\"create\": [\"~/a\", \"~/existing\"]}])\n    run_dotbot(\"-n\", \"-v\")\n\n    directory = os.path.abspath(os.path.expanduser(\"~/a\"))\n    assert not os.path.exists(directory)\n\n    lines = capfd.readouterr().out.splitlines()\n    assert any(line.strip() == f\"Would create path {os.path.join(home, 'a')}\" for line in lines)\n    assert any(f\"Path exists {os.path.join(home, 'existing')}\" == line.strip() for line in lines)\n"
  },
  {
    "path": "tests/test_link.py",
    "content": "import os\nimport pathlib\nimport stat\nimport sys\nfrom datetime import datetime, timedelta, timezone\nfrom typing import Any, Callable, Dict, List, Optional\nfrom unittest.mock import patch\n\nimport pytest\n\nfrom dotbot.plugins.link import Link\nfrom tests.conftest import Dotfiles\n\n\ndef test_link_canonicalization(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:\n    \"\"\"Verify links to symlinked targets are canonical.\n\n    \"Canonical\", here, means that dotbot does not create symlinks\n    that point to intermediary symlinks.\n    \"\"\"\n\n    dotfiles.write(\"f\", \"apple\")\n    dotfiles.write_config([{\"link\": {\"~/.f\": {\"path\": \"f\"}}}])\n\n    # Point to the config file in a symlinked dotfiles directory.\n    dotfiles_symlink = os.path.join(home, \"dotfiles-symlink\")\n    os.symlink(dotfiles.directory, dotfiles_symlink)\n    config_file = os.path.join(dotfiles_symlink, os.path.basename(dotfiles.config_filename))\n    run_dotbot(\"-c\", config_file, custom=True)\n\n    expected = os.path.join(dotfiles.directory, \"f\")\n    actual = os.readlink(os.path.abspath(os.path.expanduser(\"~/.f\")))\n    if sys.platform == \"win32\" and actual.startswith(\"\\\\\\\\?\\\\\"):\n        actual = actual[4:]\n    assert expected == actual\n\n\n@pytest.mark.parametrize(\"dst\", [\"~/.f\", \"~/f\"])\n@pytest.mark.parametrize(\"include_force\", [True, False])\ndef test_link_default_target(\n    dst: str,\n    include_force: bool,  # noqa: FBT001\n    home: str,\n    dotfiles: Dotfiles,\n    run_dotbot: Callable[..., None],\n) -> None:\n    \"\"\"Verify that default targets are calculated correctly.\n\n    This test includes verifying files with and without leading periods,\n    as well as verifying handling of None dict values.\n    \"\"\"\n\n    _ = home\n    dotfiles.write(\"f\", \"apple\")\n    config = [\n        {\n            \"link\": {\n                dst: {\"force\": False} if include_force else None,\n            }\n        }\n    ]\n    dotfiles.write_config(config)\n    run_dotbot()\n\n    with open(os.path.abspath(os.path.expanduser(dst))) as file:\n        assert file.read() == \"apple\"\n\n\ndef test_link_environment_user_expansion_link_name(\n    home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]\n) -> None:\n    \"\"\"Verify link expands user in link name.\"\"\"\n\n    _ = home\n    target = \"~/f\"\n    link_name = \"~/g\"\n    with open(os.path.abspath(os.path.expanduser(target)), \"w\") as file:\n        file.write(\"apple\")\n    dotfiles.write_config([{\"link\": {link_name: target}}])\n    run_dotbot()\n\n    with open(os.path.abspath(os.path.expanduser(link_name))) as file:\n        assert file.read() == \"apple\"\n\n\ndef test_link_environment_variable_expansion_target(\n    monkeypatch: pytest.MonkeyPatch, home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]\n) -> None:\n    \"\"\"Verify link expands environment variables in target.\"\"\"\n\n    _ = home\n    monkeypatch.setenv(\"APPLE\", \"h\")\n    link_name = \"~/.i\"\n    target = \"$APPLE\"\n    dotfiles.write(\"h\", \"grape\")\n    dotfiles.write_config([{\"link\": {link_name: target}}])\n    run_dotbot()\n\n    with open(os.path.abspath(os.path.expanduser(link_name))) as file:\n        assert file.read() == \"grape\"\n\n\ndef test_link_environment_variable_expansion_target_extended(\n    monkeypatch: pytest.MonkeyPatch, home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]\n) -> None:\n    \"\"\"Verify link expands environment variables in extended config syntax.\"\"\"\n\n    _ = home\n    monkeypatch.setenv(\"APPLE\", \"h\")\n    link_name = \"~/.i\"\n    target = \"$APPLE\"\n    dotfiles.write(\"h\", \"grape\")\n    dotfiles.write_config([{\"link\": {link_name: {\"path\": target, \"relink\": True}}}])\n    run_dotbot()\n\n    with open(os.path.abspath(os.path.expanduser(link_name))) as file:\n        assert file.read() == \"grape\"\n\n\ndef test_link_environment_variable_expansion_link_name(\n    monkeypatch: pytest.MonkeyPatch, home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]\n) -> None:\n    \"\"\"Verify link expands environment variables in link name.\n\n    If the variable doesn't exist, the \"variable\" must not be replaced.\n    \"\"\"\n\n    monkeypatch.setenv(\"ORANGE\", \".config\")\n    monkeypatch.setenv(\"BANANA\", \"g\")\n    monkeypatch.delenv(\"PEAR\", raising=False)\n\n    dotfiles.write(\"f\", \"apple\")\n    dotfiles.write(\"h\", \"grape\")\n\n    config = [\n        {\n            \"link\": {\n                \"~/${ORANGE}/$BANANA\": {\n                    \"path\": \"f\",\n                    \"create\": True,\n                },\n                \"~/$PEAR\": \"h\",\n            }\n        }\n    ]\n    dotfiles.write_config(config)\n    run_dotbot()\n\n    with open(os.path.join(home, \".config\", \"g\")) as file:\n        assert file.read() == \"apple\"\n    with open(os.path.join(home, \"$PEAR\")) as file:\n        assert file.read() == \"grape\"\n\n\ndef test_link_environment_variable_unset(\n    monkeypatch: pytest.MonkeyPatch, home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]\n) -> None:\n    \"\"\"Verify link leaves unset environment variables.\"\"\"\n\n    monkeypatch.delenv(\"ORANGE\", raising=False)\n    dotfiles.write(\"$ORANGE\", \"apple\")\n    dotfiles.write_config([{\"link\": {\"~/f\": \"$ORANGE\"}}])\n    run_dotbot()\n\n    with open(os.path.join(home, \"f\")) as file:\n        assert file.read() == \"apple\"\n\n\ndef test_link_force_leaves_when_nonexistent(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:\n    \"\"\"Verify force doesn't erase existing files when targets are nonexistent.\"\"\"\n\n    os.mkdir(os.path.join(home, \"dir\"))\n    open(os.path.join(home, \"file\"), \"w\").close()\n    config = [\n        {\n            \"link\": {\n                \"~/dir\": {\"path\": \"dir\", \"force\": True},\n                \"~/file\": {\"path\": \"file\", \"force\": True},\n            }\n        }\n    ]\n    dotfiles.write_config(config)\n    with pytest.raises(SystemExit):\n        run_dotbot()\n\n    assert os.path.isdir(os.path.join(home, \"dir\"))\n    assert os.path.isfile(os.path.join(home, \"file\"))\n\n\ndef test_link_force_overwrite_symlink(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:\n    \"\"\"Verify force overwrites a symlinked directory.\"\"\"\n\n    os.mkdir(os.path.join(home, \"dir\"))\n    dotfiles.write(\"dir/f\")\n    os.symlink(home, os.path.join(home, \".dir\"))\n\n    config = [{\"link\": {\"~/.dir\": {\"path\": \"dir\", \"force\": True}}}]\n    dotfiles.write_config(config)\n    run_dotbot()\n\n    assert os.path.isfile(os.path.join(home, \".dir\", \"f\"))\n\n\ndef test_link_force_directory(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:\n    \"\"\"Verify force deletes directories.\"\"\"\n\n    os.mkdir(os.path.join(home, \".dir\"))\n    with open(os.path.join(home, \".dir\", \"f\"), \"w\") as f:\n        f.write(\"apple\")\n    dotfiles.write(\"dir/f\", \"banana\")\n\n    config = [{\"link\": {\"~/.dir\": {\"path\": \"dir\", \"force\": True}}}]\n    dotfiles.write_config(config)\n    run_dotbot()\n\n    with open(os.path.join(home, \".dir\", \"f\")) as file:\n        assert file.read() == \"banana\"\n\n\ndef test_link_backup_directory(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:\n    \"\"\"Verify that a backup directory is created if destination directory exists.\"\"\"\n\n    os.mkdir(os.path.join(home, \".dir\"))\n    with open(os.path.join(home, \".dir\", \"f\"), \"w\") as f:\n        f.write(\"apple\")\n    dotfiles.write(\"dir/f\", \"banana\")\n\n    config = [{\"link\": {\"~/.dir\": {\"path\": \"dir\", \"backup\": True}}}]\n    dotfiles.write_config(config)\n    run_dotbot()\n\n    backup_dirs = [d for d in os.listdir(home) if d.startswith(\".dir.dotbot-backup\")]\n    assert len(backup_dirs) == 1\n    with open(os.path.join(home, backup_dirs[0], \"f\")) as file:\n        assert file.read() == \"apple\"\n    with open(os.path.join(home, \".dir\", \"f\")) as file:\n        assert file.read() == \"banana\"\n\n\ndef test_link_backup_file(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:\n    \"\"\"Verify that a backup file is created if destination file exists.\"\"\"\n\n    with open(os.path.join(home, \".file\"), \"w\") as f:\n        f.write(\"apple\")\n    dotfiles.write(\"file\", \"banana\")\n\n    config = [{\"link\": {\"~/.file\": {\"path\": \"file\", \"backup\": True}}}]\n    dotfiles.write_config(config)\n    run_dotbot()\n\n    backup_files = [f for f in os.listdir(home) if f.startswith(\".file.dotbot-backup\")]\n    assert len(backup_files) == 1\n    with open(os.path.join(home, backup_files[0])) as file:\n        assert file.read() == \"apple\"\n    with open(os.path.join(home, \".file\")) as file:\n        assert file.read() == \"banana\"\n\n\ndef test_link_backup_not_created_if_link(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:\n    \"\"\"Verify that a backup file isn't created if destination is a symlink.\"\"\"\n\n    open(os.path.join(home, \"file\"), \"w\").close()\n    dotfiles.write(\"file\")\n    os.symlink(os.path.join(home, \"file\"), os.path.join(home, \".file\"))\n\n    config = [{\"link\": {\"~/.file\": {\"path\": \"file\", \"backup\": True}}}]\n    dotfiles.write_config(config)\n    with pytest.raises(SystemExit):\n        run_dotbot()\n\n    assert not os.path.exists(os.path.join(home, \".file.dotbot-backup\"))\n\n\ndef test_link_backup_created_if_force(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:\n    \"\"\"Verify that backups are created when the force option is used.\"\"\"\n\n    with open(os.path.join(home, \".file\"), \"w\") as f:\n        f.write(\"apple\")\n    dotfiles.write(\"file\", \"banana\")\n\n    config = [{\"link\": {\"~/.file\": {\"path\": \"file\", \"backup\": True, \"force\": True}}}]\n    dotfiles.write_config(config)\n    run_dotbot()\n\n    backup_files = [f for f in os.listdir(home) if f.startswith(\".file.dotbot-backup\")]\n    assert len(backup_files) == 1\n    with open(os.path.join(home, backup_files[0])) as file:\n        assert file.read() == \"apple\"\n    with open(os.path.join(home, \".file\")) as file:\n        assert file.read() == \"banana\"\n\n\ndef test_link_backup_error_if_dest_already_exists(\n    home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]\n) -> None:\n    \"\"\"Verify an error is thrown if the backup already exists.\"\"\"\n\n    os.mkdir(os.path.join(home, \".dir\"))\n    # create fake backup directories; this test is technically timing dependent but it should work out in practice\n    now = datetime.now(timezone.utc).astimezone()\n    for delta in range(10):\n        timestamp = (now + timedelta(seconds=delta)).strftime(\"%Y%m%d-%H%M%S\")\n        os.mkdir(os.path.join(home, f\".dir.dotbot-backup.{timestamp}\"))\n        with open(os.path.join(home, f\".dir.dotbot-backup.{timestamp}\", \"f\"), \"w\") as file:\n            file.write(\"apple\")\n    dotfiles.write(\"dir\")\n\n    config = [{\"link\": {\"~/.dir\": {\"path\": \"dir\", \"backup\": True}}}]\n    dotfiles.write_config(config)\n    with pytest.raises(SystemExit):\n        run_dotbot()\n\n    for delta in range(10):\n        timestamp = (now + timedelta(seconds=delta)).strftime(\"%Y%m%d-%H%M%S\")\n        with open(os.path.join(home, f\".dir.dotbot-backup.{timestamp}\", \"f\")) as file:\n            assert file.read() == \"apple\"\n\n\ndef test_link_backup_glob(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:\n    \"\"\"Verify that backup works with globbing.\"\"\"\n    dotfiles.write(\"bin/a\", \"apple\")\n    dotfiles.write(\"bin/b\", \"banana\")\n    dotfiles.write(\"bin/c\", \"cherry\")\n\n    os.mkdir(os.path.join(home, \"bin\"))\n    with open(os.path.join(home, \"bin\", \"a\"), \"w\") as f:\n        f.write(\"apricot\")\n    with open(os.path.join(home, \"bin\", \"b\"), \"w\") as f:\n        f.write(\"blueberry\")\n    with open(os.path.join(home, \"bin\", \"c\"), \"w\") as f:\n        f.write(\"cranberry\")\n\n    config = [\n        {\n            \"defaults\": {\"link\": {\"glob\": True, \"create\": True, \"backup\": True}},\n        },\n        {\n            \"link\": {\"~/bin\": \"bin/*\"},\n        },\n    ]\n    dotfiles.write_config(config)\n    run_dotbot()\n\n    backup_a = [f for f in os.listdir(os.path.join(home, \"bin\")) if f.startswith(\"a.dotbot-backup\")]\n    backup_b = [f for f in os.listdir(os.path.join(home, \"bin\")) if f.startswith(\"b.dotbot-backup\")]\n    backup_c = [f for f in os.listdir(os.path.join(home, \"bin\")) if f.startswith(\"c.dotbot-backup\")]\n    assert len(backup_a) == 1\n    assert len(backup_b) == 1\n    assert len(backup_c) == 1\n    with open(os.path.join(home, \"bin\", backup_a[0])) as file:\n        assert file.read() == \"apricot\"\n    with open(os.path.join(home, \"bin\", backup_b[0])) as file:\n        assert file.read() == \"blueberry\"\n    with open(os.path.join(home, \"bin\", backup_c[0])) as file:\n        assert file.read() == \"cranberry\"\n\n    with open(os.path.join(home, \"bin\", \"a\")) as file:\n        assert file.read() == \"apple\"\n    with open(os.path.join(home, \"bin\", \"b\")) as file:\n        assert file.read() == \"banana\"\n    with open(os.path.join(home, \"bin\", \"c\")) as file:\n        assert file.read() == \"cherry\"\n\n\ndef test_link_backup_dry_run(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:\n    \"\"\"Verify that a backup file is not created if running in dry-run mode.\"\"\"\n\n    with open(os.path.join(home, \".file\"), \"w\") as f:\n        f.write(\"apple\")\n    dotfiles.write(\"file\", \"banana\")\n\n    config = [{\"link\": {\"~/.file\": {\"path\": \"file\", \"backup\": True}}}]\n    dotfiles.write_config(config)\n    run_dotbot(\"-n\")\n\n    backup_files = [f for f in os.listdir(home) if f.startswith(\".file.dotbot-backup\")]\n    assert len(backup_files) == 0\n\n\n@pytest.mark.parametrize(\"option\", [\"relink\", \"force\"])\ndef test_link_backup_relink_force_with_existing_incorrect_symlink(\n    option: str,\n    home: str,\n    dotfiles: Dotfiles,\n    run_dotbot: Callable[..., None],\n) -> None:\n    \"\"\"Verify that backup + relink/force replaces an incorrect symlink.\n\n    When an incorrect symlink exists at the destination, backup should not\n    prevent the symlink from being deleted and recreated correctly.\n    \"\"\"\n\n    dotfiles.write(\"f\", \"apple\")\n    # Create an incorrect symlink at the destination.\n    wrong_target = os.path.join(home, \"wrong_target\")\n    open(wrong_target, \"w\").close()\n    os.symlink(wrong_target, os.path.join(home, \".f\"))\n\n    config = [{\"link\": {\"~/.f\": {\"path\": \"f\", \"backup\": True, option: True}}}]\n    dotfiles.write_config(config)\n    run_dotbot()\n\n    # The symlink should now point to the correct target.\n    with open(os.path.join(home, \".f\")) as file:\n        assert file.read() == \"apple\"\n    # Symlinks are not backed up (backup only applies to real files).\n    backup_files = [f for f in os.listdir(home) if f.startswith(\".f.dotbot-backup\")]\n    assert len(backup_files) == 0\n\n\ndef test_link_backup_relink_real_file_skips_delete(\n    home: str,\n    dotfiles: Dotfiles,\n    run_dotbot: Callable[..., None],\n) -> None:\n    \"\"\"Verify that _delete is not called when backup already moved a real file.\n\n    When backup successfully renames a real file and relink is set,\n    there's no need to call _delete: the file is already gone.\n    \"\"\"\n\n    dotfiles.write(\"f\", \"apple\")\n    with open(os.path.join(home, \".f\"), \"w\") as f:\n        f.write(\"banana\")\n\n    config = [{\"link\": {\"~/.f\": {\"path\": \"f\", \"backup\": True, \"relink\": True}}}]\n    dotfiles.write_config(config)\n\n    original_delete = Link._delete  # noqa: SLF001\n    delete_was_called = False\n\n    def tracking_delete(self: Any, *args: Any, **kwargs: Any) -> Any:\n        nonlocal delete_was_called\n        delete_was_called = True\n        return original_delete(self, *args, **kwargs)\n\n    with patch.object(Link, \"_delete\", tracking_delete):\n        run_dotbot()\n\n    assert not delete_was_called\n\n    with open(os.path.join(home, \".f\")) as file:\n        assert file.read() == \"apple\"\n    backup_files = [f for f in os.listdir(home) if f.startswith(\".f.dotbot-backup\")]\n    assert len(backup_files) == 1\n    with open(os.path.join(home, backup_files[0])) as file:\n        assert file.read() == \"banana\"\n\n\ndef test_link_backup_relink_with_existing_incorrect_symlink_glob(\n    home: str,\n    dotfiles: Dotfiles,\n    run_dotbot: Callable[..., None],\n) -> None:\n    \"\"\"Verify that backup + relink replaces incorrect symlinks with globbing.\"\"\"\n\n    dotfiles.write(\"bin/a\", \"apple\")\n    dotfiles.write(\"bin/b\", \"banana\")\n\n    os.makedirs(os.path.join(home, \"bin\"))\n    # Create incorrect symlinks at the destinations.\n    wrong_target = os.path.join(home, \"wrong_target\")\n    open(wrong_target, \"w\").close()\n    os.symlink(wrong_target, os.path.join(home, \"bin\", \"a\"))\n    os.symlink(wrong_target, os.path.join(home, \"bin\", \"b\"))\n\n    config = [\n        {\"defaults\": {\"link\": {\"glob\": True, \"create\": True, \"backup\": True, \"relink\": True}}},\n        {\"link\": {\"~/bin\": \"bin/*\"}},\n    ]\n    dotfiles.write_config(config)\n    run_dotbot()\n\n    with open(os.path.join(home, \"bin\", \"a\")) as file:\n        assert file.read() == \"apple\"\n    with open(os.path.join(home, \"bin\", \"b\")) as file:\n        assert file.read() == \"banana\"\n\n\ndef test_link_glob_1(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:\n    \"\"\"Verify globbing works.\"\"\"\n\n    dotfiles.write(\"bin/a\", \"apple\")\n    dotfiles.write(\"bin/b\", \"banana\")\n    dotfiles.write(\"bin/c\", \"cherry\")\n    dotfiles.write_config(\n        [\n            {\"defaults\": {\"link\": {\"glob\": True, \"create\": True}}},\n            {\"link\": {\"~/bin\": \"bin/*\"}},\n        ]\n    )\n    run_dotbot()\n\n    with open(os.path.join(home, \"bin\", \"a\")) as file:\n        assert file.read() == \"apple\"\n    with open(os.path.join(home, \"bin\", \"b\")) as file:\n        assert file.read() == \"banana\"\n    with open(os.path.join(home, \"bin\", \"c\")) as file:\n        assert file.read() == \"cherry\"\n\n\ndef test_link_glob_2(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:\n    \"\"\"Verify globbing works with a trailing slash in the target.\"\"\"\n\n    dotfiles.write(\"bin/a\", \"apple\")\n    dotfiles.write(\"bin/b\", \"banana\")\n    dotfiles.write(\"bin/c\", \"cherry\")\n    dotfiles.write_config(\n        [\n            {\"defaults\": {\"link\": {\"glob\": True, \"create\": True}}},\n            {\"link\": {\"~/bin/\": \"bin/*\"}},\n        ]\n    )\n    run_dotbot()\n\n    with open(os.path.join(home, \"bin\", \"a\")) as file:\n        assert file.read() == \"apple\"\n    with open(os.path.join(home, \"bin\", \"b\")) as file:\n        assert file.read() == \"banana\"\n    with open(os.path.join(home, \"bin\", \"c\")) as file:\n        assert file.read() == \"cherry\"\n\n\ndef test_link_glob_3(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:\n    \"\"\"Verify globbing works with hidden (\"period-prefixed\") files.\"\"\"\n\n    dotfiles.write(\"bin/.a\", \"dot-apple\")\n    dotfiles.write(\"bin/.b\", \"dot-banana\")\n    dotfiles.write(\"bin/.c\", \"dot-cherry\")\n    dotfiles.write_config(\n        [\n            {\"defaults\": {\"link\": {\"glob\": True, \"create\": True}}},\n            {\"link\": {\"~/bin/\": \"bin/.*\"}},\n        ]\n    )\n    run_dotbot()\n\n    with open(os.path.join(home, \"bin\", \".a\")) as file:\n        assert file.read() == \"dot-apple\"\n    with open(os.path.join(home, \"bin\", \".b\")) as file:\n        assert file.read() == \"dot-banana\"\n    with open(os.path.join(home, \"bin\", \".c\")) as file:\n        assert file.read() == \"dot-cherry\"\n\n\ndef test_link_glob_4(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:\n    \"\"\"Verify globbing works at the root of the home and dotfiles directories.\"\"\"\n\n    dotfiles.write(\".a\", \"dot-apple\")\n    dotfiles.write(\".b\", \"dot-banana\")\n    dotfiles.write(\".c\", \"dot-cherry\")\n    dotfiles.write_config(\n        [\n            {\n                \"link\": {\n                    \"~\": {\n                        \"path\": \".*\",\n                        \"glob\": True,\n                    },\n                },\n            }\n        ]\n    )\n    run_dotbot()\n\n    with open(os.path.join(home, \".a\")) as file:\n        assert file.read() == \"dot-apple\"\n    with open(os.path.join(home, \".b\")) as file:\n        assert file.read() == \"dot-banana\"\n    with open(os.path.join(home, \".c\")) as file:\n        assert file.read() == \"dot-cherry\"\n\n\ndef test_link_glob_force(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:\n    \"\"\"Verify that glob/force work together.\"\"\"\n\n    os.mkdir(os.path.join(home, \"bin\"))\n    with open(os.path.join(home, \"bin\", \"a\"), \"w\") as f:\n        f.write(\"apricot\")\n    with open(os.path.join(home, \"bin\", \"b\"), \"w\") as f:\n        f.write(\"blueberry\")\n    with open(os.path.join(home, \"bin\", \"c\"), \"w\") as f:\n        f.write(\"cranberry\")\n\n    dotfiles.write(\"bin/a\", \"apple\")\n    dotfiles.write(\"bin/b\", \"banana\")\n    dotfiles.write(\"bin/c\", \"cherry\")\n    dotfiles.write_config(\n        [\n            {\"defaults\": {\"link\": {\"glob\": True, \"create\": True, \"force\": True}}},\n            {\"link\": {\"~/bin\": \"bin/*\"}},\n        ]\n    )\n    run_dotbot()\n\n    with open(os.path.join(home, \"bin\", \"a\")) as file:\n        assert file.read() == \"apple\"\n    with open(os.path.join(home, \"bin\", \"b\")) as file:\n        assert file.read() == \"banana\"\n    with open(os.path.join(home, \"bin\", \"c\")) as file:\n        assert file.read() == \"cherry\"\n\n\n@pytest.mark.parametrize(\"path\", [\"foo\", \"foo/\"])\ndef test_link_glob_ignore_no_glob_chars(\n    path: str, home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]\n) -> None:\n    \"\"\"Verify ambiguous link globbing fails.\"\"\"\n\n    dotfiles.makedirs(\"foo\")\n    dotfiles.write_config(\n        [\n            {\n                \"link\": {\n                    \"~/foo/\": {\n                        \"path\": path,\n                        \"glob\": True,\n                    }\n                }\n            }\n        ]\n    )\n    run_dotbot()\n    assert os.path.islink(os.path.join(home, \"foo\"))\n    assert os.path.exists(os.path.join(home, \"foo\"))\n\n\ndef test_link_glob_exclude_1(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:\n    \"\"\"Verify link globbing with an explicit exclusion.\"\"\"\n\n    dotfiles.write(\"config/foo/a\", \"apple\")\n    dotfiles.write(\"config/bar/b\", \"banana\")\n    dotfiles.write(\"config/bar/c\", \"cherry\")\n    dotfiles.write(\"config/baz/d\", \"donut\")\n    dotfiles.write_config(\n        [\n            {\n                \"defaults\": {\n                    \"link\": {\n                        \"glob\": True,\n                        \"create\": True,\n                    },\n                },\n            },\n            {\n                \"link\": {\n                    \"~/.config/\": {\n                        \"path\": \"config/*\",\n                        \"exclude\": [\"config/baz\"],\n                    },\n                },\n            },\n        ]\n    )\n    run_dotbot()\n\n    assert not os.path.exists(os.path.join(home, \".config\", \"baz\"))\n\n    assert not os.path.islink(os.path.join(home, \".config\"))\n    assert os.path.islink(os.path.join(home, \".config\", \"foo\"))\n    assert os.path.islink(os.path.join(home, \".config\", \"bar\"))\n    with open(os.path.join(home, \".config\", \"foo\", \"a\")) as file:\n        assert file.read() == \"apple\"\n    with open(os.path.join(home, \".config\", \"bar\", \"b\")) as file:\n        assert file.read() == \"banana\"\n    with open(os.path.join(home, \".config\", \"bar\", \"c\")) as file:\n        assert file.read() == \"cherry\"\n\n\ndef test_link_glob_exclude_2(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:\n    \"\"\"Verify deep link globbing with a globbed exclusion.\"\"\"\n\n    dotfiles.write(\"config/foo/a\", \"apple\")\n    dotfiles.write(\"config/bar/b\", \"banana\")\n    dotfiles.write(\"config/bar/c\", \"cherry\")\n    dotfiles.write(\"config/baz/d\", \"donut\")\n    dotfiles.write(\"config/baz/buzz/e\", \"egg\")\n    dotfiles.write_config(\n        [\n            {\n                \"defaults\": {\n                    \"link\": {\n                        \"glob\": True,\n                        \"create\": True,\n                    },\n                },\n            },\n            {\n                \"link\": {\n                    \"~/.config/\": {\n                        \"path\": \"config/*/*\",\n                        \"exclude\": [\"config/baz/*\"],\n                    },\n                },\n            },\n        ]\n    )\n    run_dotbot()\n\n    assert not os.path.exists(os.path.join(home, \".config\", \"baz\"))\n\n    assert not os.path.islink(os.path.join(home, \".config\"))\n    assert not os.path.islink(os.path.join(home, \".config\", \"foo\"))\n    assert not os.path.islink(os.path.join(home, \".config\", \"bar\"))\n    assert os.path.islink(os.path.join(home, \".config\", \"foo\", \"a\"))\n    with open(os.path.join(home, \".config\", \"foo\", \"a\")) as file:\n        assert file.read() == \"apple\"\n    with open(os.path.join(home, \".config\", \"bar\", \"b\")) as file:\n        assert file.read() == \"banana\"\n    with open(os.path.join(home, \".config\", \"bar\", \"c\")) as file:\n        assert file.read() == \"cherry\"\n\n\ndef test_link_glob_exclude_3(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:\n    \"\"\"Verify deep link globbing with an explicit exclusion.\"\"\"\n\n    dotfiles.write(\"config/foo/a\", \"apple\")\n    dotfiles.write(\"config/bar/b\", \"banana\")\n    dotfiles.write(\"config/bar/c\", \"cherry\")\n    dotfiles.write(\"config/baz/d\", \"donut\")\n    dotfiles.write(\"config/baz/buzz/e\", \"egg\")\n    dotfiles.write(\"config/baz/bizz/g\", \"grape\")\n    dotfiles.write_config(\n        [\n            {\n                \"defaults\": {\n                    \"link\": {\n                        \"glob\": True,\n                        \"create\": True,\n                    },\n                },\n            },\n            {\n                \"link\": {\n                    \"~/.config/\": {\n                        \"path\": \"config/*/*\",\n                        \"exclude\": [\"config/baz/buzz\"],\n                    },\n                },\n            },\n        ]\n    )\n    run_dotbot()\n\n    assert not os.path.exists(os.path.join(home, \".config\", \"baz\", \"buzz\"))\n\n    assert not os.path.islink(os.path.join(home, \".config\"))\n    assert not os.path.islink(os.path.join(home, \".config\", \"foo\"))\n    assert not os.path.islink(os.path.join(home, \".config\", \"bar\"))\n    assert not os.path.islink(os.path.join(home, \".config\", \"baz\"))\n    assert os.path.islink(os.path.join(home, \".config\", \"baz\", \"bizz\"))\n    assert os.path.islink(os.path.join(home, \".config\", \"foo\", \"a\"))\n    with open(os.path.join(home, \".config\", \"foo\", \"a\")) as file:\n        assert file.read() == \"apple\"\n    with open(os.path.join(home, \".config\", \"bar\", \"b\")) as file:\n        assert file.read() == \"banana\"\n    with open(os.path.join(home, \".config\", \"bar\", \"c\")) as file:\n        assert file.read() == \"cherry\"\n    with open(os.path.join(home, \".config\", \"baz\", \"d\")) as file:\n        assert file.read() == \"donut\"\n    with open(os.path.join(home, \".config\", \"baz\", \"bizz\", \"g\")) as file:\n        assert file.read() == \"grape\"\n\n\ndef test_link_glob_exclude_4(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:\n    \"\"\"Verify deep link globbing with multiple globbed exclusions.\"\"\"\n\n    dotfiles.write(\"config/foo/a\", \"apple\")\n    dotfiles.write(\"config/bar/b\", \"banana\")\n    dotfiles.write(\"config/bar/c\", \"cherry\")\n    dotfiles.write(\"config/baz/d\", \"donut\")\n    dotfiles.write(\"config/baz/buzz/e\", \"egg\")\n    dotfiles.write(\"config/baz/bizz/g\", \"grape\")\n    dotfiles.write(\"config/fiz/f\", \"fig\")\n    dotfiles.write_config(\n        [\n            {\n                \"defaults\": {\n                    \"link\": {\n                        \"glob\": True,\n                        \"create\": True,\n                    },\n                },\n            },\n            {\n                \"link\": {\n                    \"~/.config/\": {\n                        \"path\": \"config/*/*\",\n                        \"exclude\": [\"config/baz/*\", \"config/fiz/*\"],\n                    },\n                },\n            },\n        ]\n    )\n    run_dotbot()\n\n    assert not os.path.exists(os.path.join(home, \".config\", \"baz\"))\n    assert not os.path.exists(os.path.join(home, \".config\", \"fiz\"))\n\n    assert not os.path.islink(os.path.join(home, \".config\"))\n    assert not os.path.islink(os.path.join(home, \".config\", \"foo\"))\n    assert not os.path.islink(os.path.join(home, \".config\", \"bar\"))\n    assert os.path.islink(os.path.join(home, \".config\", \"foo\", \"a\"))\n    with open(os.path.join(home, \".config\", \"foo\", \"a\")) as file:\n        assert file.read() == \"apple\"\n    with open(os.path.join(home, \".config\", \"bar\", \"b\")) as file:\n        assert file.read() == \"banana\"\n    with open(os.path.join(home, \".config\", \"bar\", \"c\")) as file:\n        assert file.read() == \"cherry\"\n\n\ndef test_link_glob_multi_star(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:\n    \"\"\"Verify link globbing with deep-nested stars.\"\"\"\n\n    dotfiles.write(\"config/foo/a\", \"apple\")\n    dotfiles.write(\"config/bar/b\", \"banana\")\n    dotfiles.write(\"config/bar/c\", \"cherry\")\n    dotfiles.write_config(\n        [\n            {\"defaults\": {\"link\": {\"glob\": True, \"create\": True}}},\n            {\"link\": {\"~/.config/\": \"config/*/*\"}},\n        ]\n    )\n    run_dotbot()\n\n    assert not os.path.islink(os.path.join(home, \".config\"))\n    assert not os.path.islink(os.path.join(home, \".config\", \"foo\"))\n    assert not os.path.islink(os.path.join(home, \".config\", \"bar\"))\n    assert os.path.islink(os.path.join(home, \".config\", \"foo\", \"a\"))\n    with open(os.path.join(home, \".config\", \"foo\", \"a\")) as file:\n        assert file.read() == \"apple\"\n    with open(os.path.join(home, \".config\", \"bar\", \"b\")) as file:\n        assert file.read() == \"banana\"\n    with open(os.path.join(home, \".config\", \"bar\", \"c\")) as file:\n        assert file.read() == \"cherry\"\n\n\n@pytest.mark.parametrize(\n    (\"pattern\", \"expect_file\"),\n    [\n        (\"conf/*\", lambda fruit: fruit),\n        (\"conf/.*\", lambda fruit: \".\" + fruit),\n        (\"conf/[bc]*\", lambda fruit: fruit if fruit[0] in \"bc\" else None),\n        (\"conf/*e\", lambda fruit: fruit if fruit[-1] == \"e\" else None),\n        (\"conf/??r*\", lambda fruit: fruit if fruit[2] == \"r\" else None),\n    ],\n)\ndef test_link_glob_patterns(\n    pattern: str,\n    expect_file: Callable[[str], Optional[str]],\n    home: str,\n    dotfiles: Dotfiles,\n    run_dotbot: Callable[..., None],\n) -> None:\n    \"\"\"Verify link glob pattern matching.\"\"\"\n\n    fruits = [\"apple\", \"apricot\", \"banana\", \"cherry\", \"currant\", \"cantalope\"]\n    for fruit in fruits:\n        dotfiles.write(\"conf/\" + fruit, fruit)\n        dotfiles.write(\"conf/.\" + fruit, \"dot-\" + fruit)\n    dotfiles.write_config(\n        [\n            {\"defaults\": {\"link\": {\"glob\": True, \"create\": True}}},\n            {\"link\": {\"~/globtest\": pattern}},\n        ]\n    )\n    run_dotbot()\n\n    for fruit in fruits:\n        expected = expect_file(fruit)\n        if expected is None:\n            assert not os.path.exists(os.path.join(home, \"globtest\", fruit))\n            assert not os.path.exists(os.path.join(home, \"globtest\", \".\" + fruit))\n        elif \".\" in expected:\n            assert not os.path.islink(os.path.join(home, \"globtest\", fruit))\n            assert os.path.islink(os.path.join(home, \"globtest\", \".\" + fruit))\n        else:  # \".\" not in expected\n            assert os.path.islink(os.path.join(home, \"globtest\", fruit))\n            assert not os.path.islink(os.path.join(home, \"globtest\", \".\" + fruit))\n\n\ndef test_link_glob_recursive(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:\n    \"\"\"Verify recursive link globbing and exclusions.\"\"\"\n\n    dotfiles.write(\"config/foo/bar/a\", \"apple\")\n    dotfiles.write(\"config/foo/bar/b\", \"banana\")\n    dotfiles.write(\"config/foo/bar/c\", \"cherry\")\n    dotfiles.write_config(\n        [\n            {\"defaults\": {\"link\": {\"glob\": True, \"create\": True}}},\n            {\"link\": {\"~/.config/\": {\"path\": \"config/**\", \"exclude\": [\"config/**/b\"]}}},\n        ]\n    )\n    run_dotbot()\n\n    assert not os.path.islink(os.path.join(home, \".config\"))\n    assert not os.path.islink(os.path.join(home, \".config\", \"foo\"))\n    assert not os.path.islink(os.path.join(home, \".config\", \"foo\", \"bar\"))\n    assert os.path.islink(os.path.join(home, \".config\", \"foo\", \"bar\", \"a\"))\n    assert not os.path.exists(os.path.join(home, \".config\", \"foo\", \"bar\", \"b\"))\n    assert os.path.islink(os.path.join(home, \".config\", \"foo\", \"bar\", \"c\"))\n    with open(os.path.join(home, \".config\", \"foo\", \"bar\", \"a\")) as file:\n        assert file.read() == \"apple\"\n    with open(os.path.join(home, \".config\", \"foo\", \"bar\", \"c\")) as file:\n        assert file.read() == \"cherry\"\n\n\ndef test_link_glob_no_match(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:\n    \"\"\"Verify that a glob with no match doesn't raise an error.\"\"\"\n\n    _ = home\n    dotfiles.makedirs(\"foo\")\n    dotfiles.write_config(\n        [\n            {\"defaults\": {\"link\": {\"glob\": True, \"create\": True}}},\n            {\"link\": {\"~/.config/foo\": \"foo/*\"}},\n        ]\n    )\n    run_dotbot()\n\n\ndef test_link_glob_single_match(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:\n    \"\"\"Verify linking works even when glob matches exactly one file.\"\"\"\n    # regression test for https://github.com/anishathalye/dotbot/issues/282\n\n    dotfiles.write(\"foo/a\", \"apple\")\n    dotfiles.write_config(\n        [\n            {\"defaults\": {\"link\": {\"glob\": True, \"create\": True}}},\n            {\"link\": {\"~/.config/foo\": \"foo/*\"}},\n        ]\n    )\n    run_dotbot()\n\n    assert not os.path.islink(os.path.join(home, \".config\"))\n    assert not os.path.islink(os.path.join(home, \".config\", \"foo\"))\n    assert os.path.islink(os.path.join(home, \".config\", \"foo\", \"a\"))\n    with open(os.path.join(home, \".config\", \"foo\", \"a\")) as file:\n        assert file.read() == \"apple\"\n\n\n@pytest.mark.skipif(\n    \"sys.platform == 'win32'\",\n    reason=\"These if commands won't run on Windows\",\n)\ndef test_link_if(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:\n    \"\"\"Verify 'if' directives are checked when linking.\"\"\"\n\n    os.mkdir(os.path.join(home, \"d\"))\n    dotfiles.write(\"f\", \"apple\")\n    dotfiles.write_config(\n        [\n            {\n                \"link\": {\n                    \"~/.f\": {\"path\": \"f\", \"if\": \"true\"},\n                    \"~/.g\": {\"path\": \"f\", \"if\": \"false\"},\n                    \"~/.h\": {\"path\": \"f\", \"if\": \"[ -d ~/d ]\"},\n                    \"~/.i\": {\"path\": \"f\", \"if\": \"badcommand\"},\n                },\n            }\n        ]\n    )\n    run_dotbot()\n\n    assert not os.path.exists(os.path.join(home, \".g\"))\n    assert not os.path.exists(os.path.join(home, \".i\"))\n    with open(os.path.join(home, \".f\")) as file:\n        assert file.read() == \"apple\"\n    with open(os.path.join(home, \".h\")) as file:\n        assert file.read() == \"apple\"\n\n\n@pytest.mark.skipif(\n    \"sys.platform == 'win32'\",\n    reason=\"These if commands won't run on Windows\",\n)\ndef test_link_if_defaults(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:\n    \"\"\"Verify 'if' directive defaults are checked when linking.\"\"\"\n\n    os.mkdir(os.path.join(home, \"d\"))\n    dotfiles.write(\"f\", \"apple\")\n    dotfiles.write_config(\n        [\n            {\n                \"defaults\": {\n                    \"link\": {\n                        \"if\": \"false\",\n                    },\n                },\n            },\n            {\n                \"link\": {\n                    \"~/.j\": {\"path\": \"f\", \"if\": \"true\"},\n                    \"~/.k\": {\"path\": \"f\"},  # default is false\n                },\n            },\n        ]\n    )\n    run_dotbot()\n\n    assert not os.path.exists(os.path.join(home, \".k\"))\n    with open(os.path.join(home, \".j\")) as file:\n        assert file.read() == \"apple\"\n\n\n@pytest.mark.skipif(\n    \"sys.platform != 'win32'\",\n    reason=\"These if commands only run on Windows\",\n)\ndef test_link_if_windows(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:\n    \"\"\"Verify 'if' directives are checked when linking (Windows only).\"\"\"\n\n    os.mkdir(os.path.join(home, \"d\"))\n    dotfiles.write(\"f\", \"apple\")\n    dotfiles.write_config(\n        [\n            {\n                \"link\": {\n                    \"~/.f\": {\"path\": \"f\", \"if\": 'cmd /c \"exit 0\"'},\n                    \"~/.g\": {\"path\": \"f\", \"if\": 'cmd /c \"exit 1\"'},\n                    \"~/.h\": {\"path\": \"f\", \"if\": 'cmd /c \"dir %USERPROFILE%\\\\d'},\n                    \"~/.i\": {\"path\": \"f\", \"if\": 'cmd /c \"badcommand\"'},\n                },\n            }\n        ]\n    )\n    run_dotbot()\n\n    assert not os.path.exists(os.path.join(home, \".g\"))\n    assert not os.path.exists(os.path.join(home, \".i\"))\n    with open(os.path.join(home, \".f\")) as file:\n        assert file.read() == \"apple\"\n    with open(os.path.join(home, \".h\")) as file:\n        assert file.read() == \"apple\"\n\n\n@pytest.mark.skipif(\n    \"sys.platform != 'win32'\",\n    reason=\"These if commands only run on Windows.\",\n)\ndef test_link_if_defaults_windows(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:\n    \"\"\"Verify 'if' directive defaults are checked when linking (Windows only).\"\"\"\n\n    os.mkdir(os.path.join(home, \"d\"))\n    dotfiles.write(\"f\", \"apple\")\n    dotfiles.write_config(\n        [\n            {\n                \"defaults\": {\n                    \"link\": {\n                        \"if\": 'cmd /c \"exit 1\"',\n                    },\n                },\n            },\n            {\n                \"link\": {\n                    \"~/.j\": {\"path\": \"f\", \"if\": 'cmd /c \"exit 0\"'},\n                    \"~/.k\": {\"path\": \"f\"},  # default is false\n                },\n            },\n        ]\n    )\n    run_dotbot()\n\n    assert not os.path.exists(os.path.join(home, \".k\"))\n    with open(os.path.join(home, \".j\")) as file:\n        assert file.read() == \"apple\"\n\n\n@pytest.mark.parametrize(\"ignore_missing\", [True, False])\ndef test_link_ignore_missing(\n    ignore_missing: bool,  # noqa: FBT001\n    home: str,\n    dotfiles: Dotfiles,\n    run_dotbot: Callable[..., None],\n) -> None:\n    \"\"\"Verify link 'ignore_missing' is respected when the target is missing.\"\"\"\n\n    dotfiles.write_config(\n        [\n            {\n                \"link\": {\n                    \"~/missing_link\": {\n                        \"path\": \"missing\",\n                        \"ignore-missing\": ignore_missing,\n                    },\n                },\n            }\n        ]\n    )\n\n    if ignore_missing:\n        run_dotbot()\n        assert os.path.islink(os.path.join(home, \"missing_link\"))\n    else:\n        with pytest.raises(SystemExit):\n            run_dotbot()\n\n\ndef test_link_leaves_file(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:\n    \"\"\"Verify relink does not overwrite file.\"\"\"\n\n    dotfiles.write(\"f\", \"apple\")\n    with open(os.path.join(home, \".f\"), \"w\") as file:\n        file.write(\"grape\")\n    dotfiles.write_config([{\"link\": {\"~/.f\": \"f\"}}])\n    with pytest.raises(SystemExit):\n        run_dotbot()\n\n    with open(os.path.join(home, \".f\")) as file:\n        assert file.read() == \"grape\"\n\n\n@pytest.mark.parametrize(\"key\", [\"canonicalize-path\", \"canonicalize\"])\ndef test_link_no_canonicalize(key: str, home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:\n    \"\"\"Verify link canonicalization can be disabled.\"\"\"\n\n    dotfiles.write(\"f\", \"apple\")\n    dotfiles.write_config([{\"defaults\": {\"link\": {key: False}}}, {\"link\": {\"~/.f\": {\"path\": \"f\"}}}])\n    os.symlink(\n        dotfiles.directory,\n        os.path.join(home, \"dotfiles-symlink\"),\n        target_is_directory=True,\n    )\n    run_dotbot(\n        \"-c\",\n        os.path.join(home, \"dotfiles-symlink\", os.path.basename(dotfiles.config_filename)),\n        custom=True,\n    )\n    assert \"dotfiles-symlink\" in os.readlink(os.path.join(home, \".f\"))\n\n\ndef test_link_prefix(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:\n    \"\"\"Verify link prefixes are prepended.\"\"\"\n\n    dotfiles.write(\"conf/a\", \"apple\")\n    dotfiles.write(\"conf/b\", \"banana\")\n    dotfiles.write(\"conf/c\", \"cherry\")\n    dotfiles.write_config(\n        [\n            {\n                \"link\": {\n                    \"~/\": {\n                        \"glob\": True,\n                        \"path\": \"conf/*\",\n                        \"prefix\": \".\",\n                    },\n                },\n            }\n        ]\n    )\n    run_dotbot()\n    with open(os.path.join(home, \".a\")) as file:\n        assert file.read() == \"apple\"\n    with open(os.path.join(home, \".b\")) as file:\n        assert file.read() == \"banana\"\n    with open(os.path.join(home, \".c\")) as file:\n        assert file.read() == \"cherry\"\n\n\ndef test_link_relative(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:\n    \"\"\"Test relative linking works.\"\"\"\n\n    dotfiles.write(\"f\", \"apple\")\n    dotfiles.write(\"d/e\", \"grape\")\n    dotfiles.write_config(\n        [\n            {\n                \"link\": {\n                    \"~/.f\": {\n                        \"path\": \"f\",\n                    },\n                    \"~/.frel\": {\n                        \"path\": \"f\",\n                        \"relative\": True,\n                    },\n                    \"~/nested/.frel\": {\n                        \"path\": \"f\",\n                        \"relative\": True,\n                        \"create\": True,\n                    },\n                    \"~/.d\": {\n                        \"path\": \"d\",\n                        \"relative\": True,\n                    },\n                },\n            }\n        ]\n    )\n    run_dotbot()\n\n    f = os.readlink(os.path.join(home, \".f\"))\n    if sys.platform == \"win32\" and f.startswith(\"\\\\\\\\?\\\\\"):\n        f = f[4:]\n    assert f == os.path.join(dotfiles.directory, \"f\")\n\n    frel = os.readlink(os.path.join(home, \".frel\"))\n    if sys.platform == \"win32\" and frel.startswith(\"\\\\\\\\?\\\\\"):\n        frel = frel[4:]\n    assert frel == os.path.normpath(\"../../dotfiles/f\")\n\n    nested_frel = os.readlink(os.path.join(home, \"nested\", \".frel\"))\n    if sys.platform == \"win32\" and nested_frel.startswith(\"\\\\\\\\?\\\\\"):\n        nested_frel = nested_frel[4:]\n    assert nested_frel == os.path.normpath(\"../../../dotfiles/f\")\n\n    d = os.readlink(os.path.join(home, \".d\"))\n    if sys.platform == \"win32\" and d.startswith(\"\\\\\\\\?\\\\\"):\n        d = d[4:]\n    assert d == os.path.normpath(\"../../dotfiles/d\")\n\n    with open(os.path.join(home, \".f\")) as file:\n        assert file.read() == \"apple\"\n    with open(os.path.join(home, \".frel\")) as file:\n        assert file.read() == \"apple\"\n    with open(os.path.join(home, \"nested\", \".frel\")) as file:\n        assert file.read() == \"apple\"\n    with open(os.path.join(home, \".d\", \"e\")) as file:\n        assert file.read() == \"grape\"\n\n\ndef test_link_relink_leaves_file(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:\n    \"\"\"Verify relink does not overwrite file.\"\"\"\n\n    dotfiles.write(\"f\", \"apple\")\n    with open(os.path.join(home, \".f\"), \"w\") as file:\n        file.write(\"grape\")\n    dotfiles.write_config([{\"link\": {\"~/.f\": {\"path\": \"f\", \"relink\": True}}}])\n    with pytest.raises(SystemExit):\n        run_dotbot()\n    with open(os.path.join(home, \".f\")) as file:\n        assert file.read() == \"grape\"\n\n\ndef test_link_relink_overwrite_symlink(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:\n    \"\"\"Verify relink overwrites symlinks.\"\"\"\n\n    dotfiles.write(\"f\", \"apple\")\n    with open(os.path.join(home, \"f\"), \"w\") as file:\n        file.write(\"grape\")\n    os.symlink(os.path.join(home, \"f\"), os.path.join(home, \".f\"))\n    dotfiles.write_config([{\"link\": {\"~/.f\": {\"path\": \"f\", \"relink\": True}}}])\n    run_dotbot()\n    with open(os.path.join(home, \".f\")) as file:\n        assert file.read() == \"apple\"\n\n\ndef test_link_relink_relative_leaves_file(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:\n    \"\"\"Verify relink relative does not incorrectly relink file.\"\"\"\n\n    dotfiles.write(\"f\", \"apple\")\n    with open(os.path.join(home, \".f\"), \"w\") as file:\n        file.write(\"grape\")\n    config = [\n        {\n            \"link\": {\n                \"~/.folder/f\": {\n                    \"path\": \"f\",\n                    \"create\": True,\n                    \"relative\": True,\n                },\n            },\n        }\n    ]\n    dotfiles.write_config(config)\n    run_dotbot()\n\n    mtime = os.stat(os.path.join(home, \".folder\", \"f\")).st_mtime\n\n    config[0][\"link\"][\"~/.folder/f\"][\"relink\"] = True\n    dotfiles.write_config(config)\n    run_dotbot()\n\n    new_mtime = os.stat(os.path.join(home, \".folder\", \"f\")).st_mtime\n    assert mtime == new_mtime\n\n\ndef test_target_is_not_overwritten_by_symlink_trickery(\n    capsys: pytest.CaptureFixture[str], home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]\n) -> None:\n    dotfiles_path = pathlib.Path(dotfiles.directory)\n    home_path = pathlib.Path(home)\n\n    # Setup:\n    #   *   A symlink exists from `~/.ssh` to `ssh` in the dotfiles directory.\n    #   *   Dotbot is configured to force-recreate a symlink between two paths\n    #       when, in reality, it's actually the same file when resolved.\n    ssh_config = (dotfiles_path / \"ssh/config\").absolute()\n    os.mkdir(str(ssh_config.parent))\n    ssh_config.write_text(\"preserve me!\")\n    os.symlink(str(ssh_config.parent), str(home_path / \".ssh\"))\n    dotfiles.write_config(\n        [\n            {\n                \"defaults\": {\n                    \"link\": {\n                        \"relink\": True,\n                        \"create\": True,\n                        \"force\": True,\n                    },\n                }\n            },\n            {\n                \"link\": {\n                    # When symlinks are resolved, these are actually the same file.\n                    \"~/.ssh/config\": \"ssh/config\",\n                },\n            },\n        ]\n    )\n\n    # Execute dotbot.\n    with pytest.raises(SystemExit):\n        run_dotbot()\n\n    stdout, _ = capsys.readouterr()\n    assert \"appears to be the same file\" in stdout\n    # Verify that the file was not overwritten.\n    assert ssh_config.read_text() == \"preserve me!\"\n\n\ndef test_link_defaults_1(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:\n    \"\"\"Verify that link doesn't overwrite non-dotfiles links by default.\"\"\"\n\n    with open(os.path.join(home, \"f\"), \"w\") as file:\n        file.write(\"grape\")\n    os.symlink(os.path.join(home, \"f\"), os.path.join(home, \".f\"))\n    dotfiles.write(\"f\", \"apple\")\n    dotfiles.write_config(\n        [\n            {\n                \"link\": {\"~/.f\": \"f\"},\n            }\n        ]\n    )\n    with pytest.raises(SystemExit):\n        run_dotbot()\n\n    with open(os.path.join(home, \".f\")) as file:\n        assert file.read() == \"grape\"\n\n\ndef test_link_defaults_2(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:\n    \"\"\"Verify that explicit link defaults override the implicit default.\"\"\"\n\n    with open(os.path.join(home, \"f\"), \"w\") as file:\n        file.write(\"grape\")\n    os.symlink(os.path.join(home, \"f\"), os.path.join(home, \".f\"))\n    dotfiles.write(\"f\", \"apple\")\n    dotfiles.write_config(\n        [\n            {\"defaults\": {\"link\": {\"relink\": True}}},\n            {\"link\": {\"~/.f\": \"f\"}},\n        ]\n    )\n    run_dotbot()\n\n    with open(os.path.join(home, \".f\")) as file:\n        assert file.read() == \"apple\"\n\n\n@pytest.mark.parametrize(\n    \"config\",\n    [\n        pytest.param([{\"link\": {\"~/.f\": \"f\"}}], id=\"unspecified\"),\n        pytest.param(\n            [{\"link\": {\"~/.f\": {\"path\": \"f\", \"type\": \"symlink\"}}}],\n            id=\"specified\",\n        ),\n        pytest.param(\n            [\n                {\"defaults\": {\"link\": {\"type\": \"symlink\"}}},\n                {\"link\": {\"~/.f\": \"f\"}},\n            ],\n            id=\"symlink set for all links by default\",\n        ),\n    ],\n)\ndef test_link_type_symlink(\n    config: List[Dict[str, Any]], home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]\n) -> None:\n    \"\"\"Verify that symlinks are created by default, and when specified.\"\"\"\n\n    dotfiles.write(\"f\", \"apple\")\n    dotfiles.write_config(config)\n    run_dotbot()\n\n    assert os.path.islink(os.path.join(home, \".f\"))\n\n\n@pytest.mark.parametrize(\n    \"config\",\n    [\n        pytest.param(\n            [{\"link\": {\"~/.f\": {\"path\": \"f\", \"type\": \"hardlink\"}}}],\n            id=\"specified\",\n        ),\n        pytest.param(\n            [\n                {\"defaults\": {\"link\": {\"type\": \"hardlink\"}}},\n                {\"link\": {\"~/.f\": \"f\"}},\n            ],\n            id=\"hardlink set for all links by default\",\n        ),\n    ],\n)\ndef test_link_type_hardlink(\n    config: List[Dict[str, Any]], home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]\n) -> None:\n    \"\"\"Verify that hardlinks are created when specified.\"\"\"\n\n    dotfiles.write(\"f\", \"apple\")\n    assert os.stat(os.path.join(dotfiles.directory, \"f\")).st_nlink == 1\n    dotfiles.write_config(config)\n    run_dotbot()\n\n    assert not os.path.islink(os.path.join(home, \".f\"))\n    assert os.stat(os.path.join(dotfiles.directory, \"f\")).st_nlink == 2\n    assert os.stat(os.path.join(home, \".f\")).st_nlink == 2\n\n\n@pytest.mark.parametrize(\n    \"config\",\n    [\n        pytest.param(\n            [{\"defaults\": {\"link\": {\"type\": \"default-bogus\"}}, \"link\": {}}],\n            id=\"default link type not recognized\",\n        ),\n        pytest.param(\n            [{\"link\": {\"~/.f\": {\"type\": \"specified-bogus\"}}}],\n            id=\"specified link type not recognized\",\n        ),\n    ],\n)\ndef test_unknown_link_type(\n    capsys: pytest.CaptureFixture[str],\n    config: List[Dict[str, Any]],\n    dotfiles: Dotfiles,\n    run_dotbot: Callable[..., None],\n) -> None:\n    \"\"\"Verify that unknown link types are rejected.\"\"\"\n\n    dotfiles.write_config(config)\n    with pytest.raises(SystemExit):\n        run_dotbot()\n    stdout, _ = capsys.readouterr()\n    assert \"link type is not recognized\" in stdout\n\n\ndef test_symlink_exists_when_hardlink_requested(\n    capsys: pytest.CaptureFixture[str],\n    home: str,\n    dotfiles: Dotfiles,\n    run_dotbot: Callable[..., None],\n) -> None:\n    \"\"\"Confirm messaging when a symlink exists but a hardlink is requested.\"\"\"\n\n    # Setup: Create a symlink to the hardlink target.\n    dotfiles.write(\"target\", \"potato\")\n    os.symlink(\n        os.path.join(dotfiles.directory, \"target\"),\n        os.path.join(home, \"hardlink\"),\n    )\n\n    # Act\n    dotfiles.write_config([{\"link\": {\"~/hardlink\": {\"path\": \"target\", \"type\": \"hardlink\"}}}])\n    with pytest.raises(SystemExit):\n        run_dotbot()\n\n    # Verify\n    stdout, _ = capsys.readouterr()\n    assert \"already exists but is a symbolic link, not a hard link\" in stdout\n\n\ndef test_hardlink_already_exists(\n    capsys: pytest.CaptureFixture[str],\n    home: str,\n    dotfiles: Dotfiles,\n    run_dotbot: Callable[..., None],\n) -> None:\n    \"\"\"Confirm messaging when the hardlink already exists.\"\"\"\n\n    dotfiles.write(\"target\", \"potato\")\n    os.link(\n        os.path.join(dotfiles.directory, \"target\"),\n        os.path.join(home, \"hardlink\"),\n    )\n\n    # Act\n    dotfiles.write_config([{\"link\": {\"~/hardlink\": {\"path\": \"target\", \"type\": \"hardlink\"}}}])\n    run_dotbot(\"-v\")\n\n    # Verify\n    stdout, _ = capsys.readouterr()\n    assert \"Link exists\" in stdout\n\n\ndef test_broken_symlink_shows_invalid_link_message(\n    capsys: pytest.CaptureFixture[str],\n    home: str,\n    dotfiles: Dotfiles,\n    run_dotbot: Callable[..., None],\n) -> None:\n    \"\"\"Verify that broken symlinks show 'Invalid link' message instead of 'Linking failed'.\"\"\"\n\n    broken_target = os.path.join(home, \"nonexistent\")\n    link_name = os.path.join(home, \".f\")\n    os.symlink(broken_target, link_name)\n\n    dotfiles.write(\"f\", \"apple\")\n    dotfiles.write_config([{\"link\": {\"~/.f\": \"f\"}}])\n\n    with pytest.raises(SystemExit):\n        run_dotbot()\n\n    stdout, _ = capsys.readouterr()\n    assert \"Invalid link\" in stdout\n    assert \"Linking failed\" not in stdout\n\n\ndef test_link_dry_run(\n    capfd: pytest.CaptureFixture[str], home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]\n) -> None:\n    \"\"\"Verify that the link plugin does not create links during a dry run.\"\"\"\n\n    dotfiles.write(\"f\", \"apple\")\n    dotfiles.write(\"g\", \"pear\")\n    dotfiles.write(\"h\", \"banana\")\n    os.symlink(os.path.join(dotfiles.directory, \"g\"), os.path.join(home, \".g\"))\n    dotfiles.write_config(\n        [{\"link\": {\"~/.f\": \"f\", \"~/.g\": {\"path\": None, \"relink\": True}, \"~/.h\": {\"path\": \"h\", \"type\": \"hardlink\"}}}]\n    )\n    run_dotbot(\"-n\", \"-v\")\n\n    assert not os.path.exists(os.path.join(home, \".f\"))\n\n    lines = capfd.readouterr().out.splitlines()\n    assert any(\n        f\"Link exists {os.path.join('~', '.g')} -> {os.path.join(dotfiles.directory, 'g')}\" == line.strip()\n        for line in lines\n    )\n    assert any(\n        f\"Would create symlink {os.path.join('~', '.f')} -> {os.path.join(dotfiles.directory, 'f')}\" == line.strip()\n        for line in lines\n    )\n    assert any(\n        f\"Would create hardlink {os.path.join('~', '.h')} -> {os.path.join(dotfiles.directory, 'h')}\" == line.strip()\n        for line in lines\n    )\n\n\ndef test_link_dry_run_if(\n    capfd: pytest.CaptureFixture[str], home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]\n) -> None:\n    \"\"\"Verify that the link plugin does run condition checks during a dry run.\"\"\"\n\n    dotfiles.write(\"f\", \"apple\")\n    dotfiles.write(\"g\", \"pear\")\n    dotfiles.write_config(\n        [\n            {\n                \"link\": {\n                    \"~/.f\": {\n                        \"path\": \"f\",\n                        \"if\": \"touch \" + os.path.join(home, \"side_effect\"),\n                    },\n                    \"~/.g\": {\n                        \"path\": \"g\",\n                        \"if\": \"false\",\n                    },\n                }\n            }\n        ]\n    )\n    run_dotbot(\"-n\")\n\n    assert os.path.exists(os.path.join(home, \"side_effect\"))\n    assert not os.path.exists(os.path.join(home, \".f\"))\n\n    lines = capfd.readouterr().out.splitlines()\n    assert any(\n        f\"Would create symlink {os.path.join('~', '.f')} -> {os.path.join(dotfiles.directory, 'f')}\" == line.strip()\n        for line in lines\n    )\n    assert not any(\"Would create symlink {os.path.join('~', '.g')}\" in line for line in lines)\n\n\ndef test_link_dry_run_create(\n    capfd: pytest.CaptureFixture[str], home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]\n) -> None:\n    \"\"\"Verify that the link plugin does not create parent directories during a dry run.\"\"\"\n\n    dotfiles.write(\"f\", \"apple\")\n    dotfiles.write_config(\n        [\n            {\n                \"link\": {\n                    \"~/.config/.f\": {\n                        \"path\": \"f\",\n                        \"create\": True,\n                    }\n                }\n            }\n        ]\n    )\n    run_dotbot(\"-n\")\n    assert not os.path.exists(os.path.join(home, \".config\"))\n    assert not os.path.exists(os.path.join(home, \".config\", \".f\"))\n\n    lines = capfd.readouterr().out.splitlines()\n    assert any(line.strip() == f\"Would create directory {os.path.join(home, '.config')}\" for line in lines)\n    assert any(\n        f\"Would create symlink {os.path.join('~', '.config', '.f')} -> {os.path.join(dotfiles.directory, 'f')}\"\n        == line.strip()\n        for line in lines\n    )\n\n\ndef test_link_dry_run_relink(\n    capfd: pytest.CaptureFixture[str], home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]\n) -> None:\n    \"\"\"Verify that the link plugin does not relink existing links during a dry run.\"\"\"\n\n    dotfiles.write(\"f\", \"apple\")\n    dotfiles.write(\"g\", \"pear\")\n    dotfiles.write_config(\n        [\n            {\n                \"link\": {\n                    \"~/.f\": {\n                        \"path\": \"f\",\n                        \"relink\": True,\n                    }\n                }\n            }\n        ]\n    )\n    os.symlink(os.path.join(dotfiles.directory, \"g\"), os.path.join(home, \".f\"))\n    run_dotbot(\"-n\")\n    with open(os.path.join(home, \".f\")) as file:\n        assert file.read() == \"pear\"\n\n    lines = capfd.readouterr().out.splitlines()\n    assert any(line.strip() == f\"Would remove {os.path.join('~', '.f')}\" for line in lines)\n    assert any(\n        f\"Would create symlink {os.path.join('~', '.f')} -> {os.path.join(dotfiles.directory, 'f')}\" == line.strip()\n        for line in lines\n    )\n\n\ndef test_link_dry_run_overwrite(\n    capfd: pytest.CaptureFixture[str], home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]\n) -> None:\n    \"\"\"Verify that the link plugin does not delete existing files during a dry run.\"\"\"\n\n    dotfiles.write(\"f\", \"apple\")\n    dotfiles.write_config(\n        [\n            {\n                \"link\": {\n                    \"~/.f\": {\n                        \"path\": \"f\",\n                        \"force\": True,\n                    }\n                }\n            }\n        ]\n    )\n    with open(os.path.join(home, \".f\"), \"w\") as file:\n        file.write(\"pear\")\n    run_dotbot(\"-n\")\n    with open(os.path.join(home, \".f\")) as file:\n        assert file.read() == \"pear\"\n\n    lines = capfd.readouterr().out.splitlines()\n    assert any(line.strip() == f\"Would remove {os.path.join('~', '.f')}\" for line in lines)\n    assert any(\n        f\"Would create symlink {os.path.join('~', '.f')} -> {os.path.join(dotfiles.directory, 'f')}\" == line.strip()\n        for line in lines\n    )\n\n\n@pytest.mark.skipif(\n    \"sys.platform == 'win32'\",\n    reason=\"Permissions work differently on Windows\",\n)\n@pytest.mark.skipif(\n    \"hasattr(os, 'getuid') and os.getuid() == 0\",\n    reason=\"Root bypasses permission checks\",\n)\ndef test_link_error_creating_link(\n    capsys: pytest.CaptureFixture[str], home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]\n) -> None:\n    \"\"\"Verify that link reports link creation errors.\"\"\"\n\n    os.makedirs(os.path.join(home, \"subdir\"))\n    dotfiles.write(\"f\", \"apple\")\n    dotfiles.write_config([{\"link\": {\"~/subdir/.f\": \"f\"}}])\n\n    # Remove all write permissions from subdir.\n    old_permissions = stat.S_IMODE(os.stat(os.path.join(home, \"subdir\")).st_mode)\n    os.chmod(\n        os.path.join(home, \"subdir\"),\n        stat.S_IRUSR | stat.S_IXUSR | stat.S_IRGRP | stat.S_IXGRP | stat.S_IROTH | stat.S_IXOTH,\n    )\n\n    with pytest.raises(SystemExit):\n        run_dotbot()\n\n    # Restore permissions to allow test cleanup.\n    os.chmod(os.path.join(home, \"subdir\"), old_permissions)\n\n    stdout, _ = capsys.readouterr()\n    assert \"Linking failed\" in stdout\n\n\n@pytest.mark.skipif(\n    \"sys.platform == 'win32'\",\n    reason=\"Permissions work differently on Windows\",\n)\n@pytest.mark.skipif(\n    \"hasattr(os, 'getuid') and os.getuid() == 0\",\n    reason=\"Root bypasses permission checks\",\n)\ndef test_link_error_creating_directory(\n    capsys: pytest.CaptureFixture[str], home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]\n) -> None:\n    \"\"\"Verify that link reports directory creation errors.\"\"\"\n\n    os.makedirs(os.path.join(home, \"subdir\"))\n    dotfiles.write(\"f\", \"apple\")\n    dotfiles.write_config([{\"link\": {\"~/subdir/subsubdir/.f\": {\"path\": \"f\", \"create\": True}}}])\n\n    # Remove all write permissions from subdir.\n    old_permissions = stat.S_IMODE(os.stat(os.path.join(home, \"subdir\")).st_mode)\n    os.chmod(\n        os.path.join(home, \"subdir\"),\n        stat.S_IRUSR | stat.S_IXUSR | stat.S_IRGRP | stat.S_IXGRP | stat.S_IROTH | stat.S_IXOTH,\n    )\n\n    with pytest.raises(SystemExit):\n        run_dotbot()\n\n    # Restore permissions to allow test cleanup.\n    os.chmod(os.path.join(home, \"subdir\"), old_permissions)\n\n    stdout, _ = capsys.readouterr()\n    assert \"Failed to create directory\" in stdout\n\n\n@pytest.mark.skipif(\n    \"sys.platform == 'win32'\",\n    reason=\"Permissions work differently on Windows\",\n)\n@pytest.mark.skipif(\n    \"hasattr(os, 'getuid') and os.getuid() == 0\",\n    reason=\"Root bypasses permission checks\",\n)\ndef test_link_error_delete(\n    capsys: pytest.CaptureFixture[str], home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]\n) -> None:\n    \"\"\"Verify that link reports deletion errors.\"\"\"\n\n    os.makedirs(os.path.join(home, \"subdir\"))\n    with open(os.path.join(home, \"subdir\", \".f\"), \"w\") as file:\n        file.write(\"grape\")\n    dotfiles.write(\"f\", \"apple\")\n    dotfiles.write_config([{\"link\": {\"~/subdir/.f\": {\"path\": \"f\", \"force\": True}}}])\n\n    # Remove all write permissions from subdir.\n    old_permissions = stat.S_IMODE(os.stat(os.path.join(home, \"subdir\")).st_mode)\n    os.chmod(\n        os.path.join(home, \"subdir\"),\n        stat.S_IRUSR | stat.S_IXUSR | stat.S_IRGRP | stat.S_IXGRP | stat.S_IROTH | stat.S_IXOTH,\n    )\n\n    with pytest.raises(SystemExit):\n        run_dotbot()\n\n    # Restore permissions to allow test cleanup.\n    os.chmod(os.path.join(home, \"subdir\"), old_permissions)\n\n    stdout, _ = capsys.readouterr()\n    assert \"Failed to remove\" in stdout\n"
  },
  {
    "path": "tests/test_noop.py",
    "content": "import os\n\nimport pytest\n\n\ndef test_success(root: str) -> None:\n    path = os.path.join(root, \"abc.txt\")\n    with open(path, \"w\") as f:\n        f.write(\"hello\")\n    with open(path) as f:\n        assert f.read() == \"hello\"\n\n\ndef test_failure() -> None:\n    with pytest.raises(AssertionError):\n        open(\"abc.txt\", \"w\")  # noqa: SIM115\n\n    with pytest.raises(AssertionError):\n        open(file=\"abc.txt\", mode=\"w\")  # noqa: SIM115\n\n    with pytest.raises(AssertionError):\n        os.mkdir(\"a\")\n\n    with pytest.raises(AssertionError):\n        os.mkdir(path=\"a\")\n"
  },
  {
    "path": "tests/test_plugins.py",
    "content": "import os\nimport shutil\nfrom typing import Callable\n\nimport pytest\n\nfrom tests.conftest import Dotfiles\n\n\ndef test_plugin_file(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:\n    \"\"\"Verify that a plugin file can be loaded in the config.\"\"\"\n\n    plugin_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), \"dotbot_plugin_file.py\")\n    shutil.copy(plugin_file, os.path.join(dotfiles.directory, \"file.py\"))\n    dotfiles.write_config(\n        [\n            {\"plugins\": [\"file.py\"]},\n            {\"plugin_file\": \"no-check-context\"},\n        ]\n    )\n    run_dotbot()\n    with open(os.path.join(home, \"flag-file\")) as file:\n        assert file.read() == \"file plugin loading works\"\n\n\ndef test_plugin_absolute_path(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:\n    \"\"\"Verify that a plugin can be loaded via absolute path.\"\"\"\n    plugin_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), \"dotbot_plugin_file.py\")\n    shutil.copy(plugin_file, os.path.join(dotfiles.directory, \"file.py\"))\n    dotfiles.write_config(\n        [\n            {\"plugins\": [os.path.join(os.path.abspath(dotfiles.directory), \"file.py\")]},\n            {\"plugin_file\": \"no-check-context\"},\n        ]\n    )\n    run_dotbot()\n    with open(os.path.join(home, \"flag-file\")) as file:\n        assert file.read() == \"file plugin loading works\"\n\n\ndef test_plugin_directory(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:\n    \"\"\"Verify that a plugin directory can be loaded in the config.\"\"\"\n    plugin_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), \"dotbot_plugin_directory.py\")\n    os.makedirs(os.path.join(dotfiles.directory, \"plugins\"))\n    shutil.copy(plugin_file, os.path.join(dotfiles.directory, \"plugins\", \"directory.py\"))\n    dotfiles.write_config(\n        [\n            {\"plugins\": [\"plugins\"]},\n            {\"plugin_directory\": \"no-check-context\"},\n        ]\n    )\n    run_dotbot()\n    with open(os.path.join(home, \"flag-directory\")) as file:\n        assert file.read() == \"directory plugin loading works\"\n\n\ndef test_plugin_multiple(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:\n    \"\"\"Verify that multiple plugins can be loaded at once in the config.\"\"\"\n    plugin_file1 = os.path.join(os.path.dirname(os.path.abspath(__file__)), \"dotbot_plugin_file.py\")\n    plugin_file2 = os.path.join(os.path.dirname(os.path.abspath(__file__)), \"dotbot_plugin_directory.py\")\n    shutil.copy(plugin_file1, os.path.join(dotfiles.directory, \"file.py\"))\n    os.makedirs(os.path.join(dotfiles.directory, \"plugins\"))\n    shutil.copy(plugin_file2, os.path.join(dotfiles.directory, \"plugins\", \"directory.py\"))\n    dotfiles.write_config(\n        [\n            {\"plugins\": [\"file.py\", \"plugins\"]},\n            {\"plugin_file\": \"no-check-context\"},\n            {\"plugin_directory\": \"no-check-context\"},\n        ]\n    )\n    run_dotbot()\n    with open(os.path.join(home, \"flag-file\")) as file:\n        assert file.read() == \"file plugin loading works\"\n    with open(os.path.join(home, \"flag-directory\")) as file:\n        assert file.read() == \"directory plugin loading works\"\n\n\ndef test_plugin_command_line_and_config(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:\n    \"\"\"Verify that plugins can be simultaneously loaded via command-line arguments and config.\"\"\"\n    plugin_file1 = os.path.join(os.path.dirname(os.path.abspath(__file__)), \"dotbot_plugin_file.py\")\n    plugin_file2 = os.path.join(os.path.dirname(os.path.abspath(__file__)), \"dotbot_plugin_directory.py\")\n    shutil.copy(plugin_file1, os.path.join(dotfiles.directory, \"file.py\"))\n    os.makedirs(os.path.join(dotfiles.directory, \"plugins\"))\n    shutil.copy(plugin_file2, os.path.join(dotfiles.directory, \"plugins\", \"directory.py\"))\n    dotfiles.write_config(\n        [\n            {\"plugins\": [\"file.py\"]},\n            {\"plugin_file\": \"no-check-context\"},\n            {\"plugin_directory\": \"no-check-context\"},\n        ]\n    )\n    run_dotbot(\"--plugin-dir\", os.path.join(dotfiles.directory, \"plugins\"))\n    with open(os.path.join(home, \"flag-file\")) as file:\n        assert file.read() == \"file plugin loading works\"\n    with open(os.path.join(home, \"flag-directory\")) as file:\n        assert file.read() == \"directory plugin loading works\"\n\n\ndef test_plugin_nonexistent(\n    capfd: pytest.CaptureFixture[str], dotfiles: Dotfiles, run_dotbot: Callable[..., None]\n) -> None:\n    \"\"\"Verify that trying to load a non-existent plugin emits a warning and error.\"\"\"\n    dotfiles.write_config(\n        [\n            {\"plugins\": [\"nonexistent.py\"]},\n            {\"plugin_file\": \"no-check-context\"},\n        ]\n    )\n    with pytest.raises(SystemExit) as excinfo:\n        run_dotbot()\n    assert excinfo.value.code == 1\n    stdout = capfd.readouterr().out.splitlines()\n    assert any(\"Failed to load plugin 'nonexistent.py'\" in line for line in stdout)\n    assert any(\"Some plugins could not be loaded\" in line for line in stdout)\n\n\ndef test_plugin_empty_list(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:\n    \"\"\"Verify that an empty plugin list doesn't cause errors.\"\"\"\n    dotfiles.write_config(\n        [\n            {\"plugins\": []},\n            {\"link\": {\"~/test\": \"test\"}},\n        ]\n    )\n    dotfiles.write(\"test\", \"content\")\n    run_dotbot()\n    with open(os.path.join(home, \"test\")) as file:\n        assert file.read() == \"content\"\n\n\ndef test_plugin_multiple_directives(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:\n    \"\"\"Verify that multiple plugin directives in the same config work correctly.\"\"\"\n    plugin_file1 = os.path.join(os.path.dirname(os.path.abspath(__file__)), \"dotbot_plugin_file.py\")\n    plugin_file2 = os.path.join(os.path.dirname(os.path.abspath(__file__)), \"dotbot_plugin_directory.py\")\n    shutil.copy(plugin_file1, os.path.join(dotfiles.directory, \"file.py\"))\n    os.makedirs(os.path.join(dotfiles.directory, \"plugins\"))\n    shutil.copy(plugin_file2, os.path.join(dotfiles.directory, \"plugins\", \"directory.py\"))\n    dotfiles.write_config(\n        [\n            {\"plugins\": [\"file.py\"]},\n            {\"plugins\": [\"plugins\"]},\n            {\"plugin_file\": \"no-check-context\"},\n            {\"plugin_directory\": \"no-check-context\"},\n        ]\n    )\n    run_dotbot()\n    with open(os.path.join(home, \"flag-file\")) as file:\n        assert file.read() == \"file plugin loading works\"\n    with open(os.path.join(home, \"flag-directory\")) as file:\n        assert file.read() == \"directory plugin loading works\"\n\n\ndef test_plugin_duplicate_loading(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:\n    \"\"\"Verify that duplicate plugin references don't load/execute the plugin multiple times.\"\"\"\n    plugin_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), \"dotbot_plugin_counter.py\")\n    shutil.copy(plugin_file, os.path.join(dotfiles.directory, \"counter.py\"))\n\n    dotfiles.write_config(\n        [\n            {\"plugins\": [\"counter.py\", \"counter.py\"]},\n            {\"counter\": {}},\n        ]\n    )\n    run_dotbot()\n\n    with open(os.path.join(home, \"counter\")) as file:\n        assert file.read() == \"1\"\n\n\ndef test_plugin_subdirectory(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:\n    \"\"\"Verify that a plugin file in a subdirectory can be loaded.\"\"\"\n    plugin_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), \"dotbot_plugin_file.py\")\n    os.makedirs(os.path.join(dotfiles.directory, \"plugins\", \"subdir\"))\n    shutil.copy(plugin_file, os.path.join(dotfiles.directory, \"plugins\", \"subdir\", \"file.py\"))\n    dotfiles.write_config(\n        [\n            {\"plugins\": [\"plugins/subdir/file.py\"]},\n            {\"plugin_file\": \"no-check-context\"},\n        ]\n    )\n    run_dotbot()\n    with open(os.path.join(home, \"flag-file\")) as file:\n        assert file.read() == \"file plugin loading works\"\n"
  },
  {
    "path": "tests/test_shell.py",
    "content": "from typing import Callable\n\nimport pytest\n\nfrom tests.conftest import Dotfiles\n\n\ndef test_shell_allow_stdout(\n    capfd: pytest.CaptureFixture[str], dotfiles: Dotfiles, run_dotbot: Callable[..., None]\n) -> None:\n    \"\"\"Verify shell command STDOUT works.\"\"\"\n\n    dotfiles.write_config(\n        [\n            {\n                \"shell\": [\n                    {\n                        \"command\": \"echo apple\",\n                        \"stdout\": True,\n                    }\n                ],\n            }\n        ]\n    )\n    run_dotbot()\n\n    output = capfd.readouterr()\n    assert any(line.startswith(\"apple\") for line in output.out.splitlines()), output\n\n\ndef test_shell_cli_verbosity_overrides_1(\n    capfd: pytest.CaptureFixture[str], dotfiles: Dotfiles, run_dotbot: Callable[..., None]\n) -> None:\n    \"\"\"Verify that '-vv' overrides the implicit default stdout=False.\"\"\"\n\n    dotfiles.write_config([{\"shell\": [{\"command\": \"echo apple\"}]}])\n    run_dotbot(\"-vv\")\n\n    lines = capfd.readouterr().out.splitlines()\n    assert any(line.startswith(\"apple\") for line in lines)\n\n\ndef test_shell_cli_verbosity_overrides_2(\n    capfd: pytest.CaptureFixture[str], dotfiles: Dotfiles, run_dotbot: Callable[..., None]\n) -> None:\n    \"\"\"Verify that '-vv' overrides an explicit stdout=False.\"\"\"\n\n    dotfiles.write_config([{\"shell\": [{\"command\": \"echo apple\", \"stdout\": False}]}])\n    run_dotbot(\"-vv\")\n\n    lines = capfd.readouterr().out.splitlines()\n    assert any(line.startswith(\"apple\") for line in lines)\n\n\ndef test_shell_cli_verbosity_overrides_3(\n    capfd: pytest.CaptureFixture[str], dotfiles: Dotfiles, run_dotbot: Callable[..., None]\n) -> None:\n    \"\"\"Verify that '-vv' overrides an explicit defaults:shell:stdout=False.\"\"\"\n\n    dotfiles.write_config(\n        [\n            {\"defaults\": {\"shell\": {\"stdout\": False}}},\n            {\"shell\": [{\"command\": \"echo apple\"}]},\n        ]\n    )\n    run_dotbot(\"-vv\")\n\n    stdout = capfd.readouterr().out.splitlines()\n    assert any(line.startswith(\"apple\") for line in stdout)\n\n\ndef test_shell_cli_verbosity_stderr(\n    capfd: pytest.CaptureFixture[str], dotfiles: Dotfiles, run_dotbot: Callable[..., None]\n) -> None:\n    \"\"\"Verify that commands can output to STDERR.\"\"\"\n\n    dotfiles.write_config([{\"shell\": [{\"command\": \"echo apple >&2\"}]}])\n    run_dotbot(\"-vv\")\n\n    stderr = capfd.readouterr().err.splitlines()\n    assert any(line.startswith(\"apple\") for line in stderr)\n\n\ndef test_shell_cli_verbosity_stderr_with_explicit_stdout_off(\n    capfd: pytest.CaptureFixture[str], dotfiles: Dotfiles, run_dotbot: Callable[..., None]\n) -> None:\n    \"\"\"Verify that commands can output to STDERR with STDOUT explicitly off.\"\"\"\n\n    dotfiles.write_config(\n        [\n            {\n                \"shell\": [\n                    {\n                        \"command\": \"echo apple >&2\",\n                        \"stdout\": False,\n                    }\n                ],\n            }\n        ]\n    )\n    run_dotbot(\"-vv\")\n\n    stderr = capfd.readouterr().err.splitlines()\n    assert any(line.startswith(\"apple\") for line in stderr)\n\n\ndef test_shell_cli_verbosity_stderr_with_defaults_stdout_off(\n    capfd: pytest.CaptureFixture[str], dotfiles: Dotfiles, run_dotbot: Callable[..., None]\n) -> None:\n    \"\"\"Verify that commands can output to STDERR with defaults:shell:stdout=False.\"\"\"\n\n    dotfiles.write_config(\n        [\n            {\n                \"defaults\": {\n                    \"shell\": {\n                        \"stdout\": False,\n                    },\n                },\n            },\n            {\n                \"shell\": [\n                    {\"command\": \"echo apple >&2\"},\n                ],\n            },\n        ]\n    )\n    run_dotbot(\"-vv\")\n\n    stderr = capfd.readouterr().err.splitlines()\n    assert any(line.startswith(\"apple\") for line in stderr)\n\n\ndef test_shell_single_v_verbosity_stdout(\n    capfd: pytest.CaptureFixture[str], dotfiles: Dotfiles, run_dotbot: Callable[..., None]\n) -> None:\n    \"\"\"Verify that a single '-v' verbosity doesn't override stdout=False.\"\"\"\n\n    dotfiles.write_config([{\"shell\": [{\"command\": \"echo apple\"}]}])\n    run_dotbot(\"-v\")\n\n    stdout = capfd.readouterr().out.splitlines()\n    assert not any(line.startswith(\"apple\") for line in stdout)\n\n\ndef test_shell_single_v_verbosity_stderr(\n    capfd: pytest.CaptureFixture[str], dotfiles: Dotfiles, run_dotbot: Callable[..., None]\n) -> None:\n    \"\"\"Verify that a single '-v' verbosity doesn't override stderr=False.\"\"\"\n\n    dotfiles.write_config([{\"shell\": [{\"command\": \"echo apple >&2\"}]}])\n    run_dotbot(\"-v\")\n\n    stderr = capfd.readouterr().err.splitlines()\n    assert not any(line.startswith(\"apple\") for line in stderr)\n\n\ndef test_shell_compact_stdout_1(\n    capfd: pytest.CaptureFixture[str], dotfiles: Dotfiles, run_dotbot: Callable[..., None]\n) -> None:\n    \"\"\"Verify that shell command stdout works in compact form.\"\"\"\n\n    dotfiles.write_config(\n        [\n            {\"defaults\": {\"shell\": {\"stdout\": True}}},\n            {\"shell\": [\"echo apple\"]},\n        ]\n    )\n    run_dotbot()\n\n    stdout = capfd.readouterr().out.splitlines()\n    assert any(line.startswith(\"apple\") for line in stdout)\n\n\ndef test_shell_compact_stdout_2(\n    capfd: pytest.CaptureFixture[str], dotfiles: Dotfiles, run_dotbot: Callable[..., None]\n) -> None:\n    \"\"\"Verify that shell command stdout works in compact form.\"\"\"\n\n    dotfiles.write_config(\n        [\n            {\"defaults\": {\"shell\": {\"stdout\": True}}},\n            {\"shell\": [[\"echo apple\", \"echoing message\"]]},\n        ]\n    )\n    run_dotbot()\n\n    stdout = capfd.readouterr().out.splitlines()\n    assert any(line.startswith(\"apple\") for line in stdout)\n    assert any(line.startswith(\"echoing message\") for line in stdout)\n\n\ndef test_shell_stdout_disabled_by_default(\n    capfd: pytest.CaptureFixture[str], dotfiles: Dotfiles, run_dotbot: Callable[..., None]\n) -> None:\n    \"\"\"Verify that the shell command disables stdout by default.\"\"\"\n\n    dotfiles.write_config(\n        [\n            {\n                \"shell\": [\"echo banana\"],\n            }\n        ]\n    )\n    run_dotbot()\n\n    stdout = capfd.readouterr().out.splitlines()\n    assert not any(line.startswith(\"banana\") for line in stdout)\n\n\ndef test_shell_can_override_defaults(\n    capfd: pytest.CaptureFixture[str], dotfiles: Dotfiles, run_dotbot: Callable[..., None]\n) -> None:\n    \"\"\"Verify that the shell command can override defaults.\"\"\"\n\n    dotfiles.write_config(\n        [\n            {\"defaults\": {\"shell\": {\"stdout\": True}}},\n            {\"shell\": [{\"command\": \"echo apple\", \"stdout\": False}]},\n        ]\n    )\n    run_dotbot()\n\n    stdout = capfd.readouterr().out.splitlines()\n    assert not any(line.startswith(\"apple\") for line in stdout)\n\n\ndef test_shell_quiet_default(\n    capfd: pytest.CaptureFixture[str], dotfiles: Dotfiles, run_dotbot: Callable[..., None]\n) -> None:\n    \"\"\"Verify that quiet is off by default.\"\"\"\n\n    dotfiles.write_config(\n        [\n            {\n                \"shell\": [\n                    {\n                        \"command\": \"echo banana\",\n                        \"description\": \"echoing a thing...\",\n                    }\n                ],\n            }\n        ]\n    )\n    run_dotbot()\n\n    stdout = capfd.readouterr().out.splitlines()\n    assert not any(line.startswith(\"banana\") for line in stdout)\n    assert any(\"echo banana\" in line for line in stdout)\n    assert any(line.startswith(\"echoing a thing...\") for line in stdout)\n\n\ndef test_shell_quiet_enabled_with_description(\n    capfd: pytest.CaptureFixture[str], dotfiles: Dotfiles, run_dotbot: Callable[..., None]\n) -> None:\n    \"\"\"Verify that only the description is shown when quiet is enabled.\"\"\"\n\n    dotfiles.write_config(\n        [\n            {\n                \"shell\": [\n                    {\n                        \"command\": \"echo banana\",\n                        \"description\": \"echoing a thing...\",\n                        \"quiet\": True,\n                    }\n                ],\n            }\n        ]\n    )\n    run_dotbot()\n\n    stdout = capfd.readouterr().out.splitlines()\n    assert not any(line.startswith(\"banana\") for line in stdout)\n    assert not any(\"echo banana\" in line for line in stdout)\n    assert any(line.startswith(\"echoing a thing...\") for line in stdout)\n\n\ndef test_shell_quiet_enabled_without_description(\n    capfd: pytest.CaptureFixture[str], dotfiles: Dotfiles, run_dotbot: Callable[..., None]\n) -> None:\n    \"\"\"Verify nothing is shown when quiet is enabled with no description.\"\"\"\n\n    dotfiles.write_config(\n        [\n            {\n                \"shell\": [\n                    {\n                        \"command\": \"echo banana\",\n                        \"quiet\": True,\n                    }\n                ],\n            }\n        ]\n    )\n    run_dotbot()\n\n    stdout = capfd.readouterr().out.splitlines()\n    assert not any(line.startswith(\"banana\") for line in stdout)\n    assert not any(line.startswith(\"echo banana\") for line in stdout)\n\n\ndef test_shell_dry_run(capfd: pytest.CaptureFixture[str], dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:\n    \"\"\"Verify that the shell plugin does not execute commands during a dry run.\"\"\"\n\n    dotfiles.write_config(\n        [\n            {\n                \"shell\": [\n                    {\n                        \"command\": \"exit 1\",\n                    }\n                ],\n            }\n        ]\n    )\n    run_dotbot(\"--dry-run\")\n\n    lines = capfd.readouterr().out.splitlines()\n    assert any(line.strip() == \"Would run command exit 1\" for line in lines)\n"
  },
  {
    "path": "tests/test_shim.py",
    "content": "import os\nimport shutil\nimport subprocess\nimport sys\n\nimport pytest\n\nfrom tests.conftest import Dotfiles\n\n\ndef test_shim(home: str, dotfiles: Dotfiles) -> None:\n    \"\"\"Verify install shim works.\"\"\"\n\n    # Skip the test if git is unavailable.\n    git = shutil.which(\"git\")\n    if git is None:\n        pytest.skip(\"git is unavailable\")\n\n    if sys.platform == \"win32\":\n        install = os.path.join(dotfiles.directory, \"dotbot\", \"tools\", \"git-submodule\", \"install.ps1\")\n        shim = os.path.join(dotfiles.directory, \"install.ps1\")\n    else:\n        install = os.path.join(dotfiles.directory, \"dotbot\", \"tools\", \"git-submodule\", \"install\")\n        shim = os.path.join(dotfiles.directory, \"install\")\n\n    # Set up the test environment.\n    git_directory = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))\n    os.chdir(dotfiles.directory)\n    subprocess.check_call([git, \"init\"])\n    subprocess.check_call([git, \"-c\", \"protocol.file.allow=always\", \"submodule\", \"add\", git_directory, \"dotbot\"])\n    shutil.copy(install, shim)\n    dotfiles.write(\"foo\", \"pear\")\n    dotfiles.write_config([{\"link\": {\"~/.foo\": \"foo\"}}])\n\n    # Run the shim script.\n    env = dict(os.environ)\n    if sys.platform == \"win32\":\n        ps = shutil.which(\"powershell\")\n        assert ps is not None\n        args = [ps, \"-ExecutionPolicy\", \"RemoteSigned\", shim]\n        env[\"USERPROFILE\"] = home\n    else:\n        args = [shim]\n        env[\"HOME\"] = home\n    subprocess.check_call(args, env=env, cwd=dotfiles.directory)\n\n    assert os.path.islink(os.path.join(home, \".foo\"))\n    with open(os.path.join(home, \".foo\")) as file:\n        assert file.read() == \"pear\"\n"
  },
  {
    "path": "tools/git-submodule/install",
    "content": "#!/usr/bin/env bash\n\nset -e\n\nCONFIG=\"install.conf.yaml\"\nDOTBOT_DIR=\"dotbot\"\n\nDOTBOT_BIN=\"bin/dotbot\"\nBASEDIR=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)\"\n\ncd \"${BASEDIR}\"\ngit -C \"${DOTBOT_DIR}\" submodule sync --quiet --recursive\ngit submodule update --init --recursive \"${DOTBOT_DIR}\"\n\n\"${BASEDIR}/${DOTBOT_DIR}/${DOTBOT_BIN}\" -d \"${BASEDIR}\" -c \"${CONFIG}\" \"${@}\"\n"
  },
  {
    "path": "tools/git-submodule/install.ps1",
    "content": "$ErrorActionPreference = \"Stop\"\n\n$CONFIG = \"install.conf.yaml\"\n$DOTBOT_DIR = \"dotbot\"\n\n$DOTBOT_BIN = \"bin/dotbot\"\n$BASEDIR = $PSScriptRoot\n\nSet-Location $BASEDIR\ngit -C $DOTBOT_DIR submodule sync --quiet --recursive\ngit submodule update --init --recursive $DOTBOT_DIR\n\nforeach ($PYTHON in ('python', 'python3')) {\n    # Python redirects to Microsoft Store in Windows 10 when not installed\n    if (& { $ErrorActionPreference = \"SilentlyContinue\"\n            ![string]::IsNullOrEmpty((&$PYTHON -V))\n            $ErrorActionPreference = \"Stop\" }) {\n        &$PYTHON $(Join-Path $BASEDIR -ChildPath $DOTBOT_DIR | Join-Path -ChildPath $DOTBOT_BIN) -d $BASEDIR -c $CONFIG $Args\n        return\n    }\n}\nWrite-Error \"Error: Cannot find Python.\"\n"
  },
  {
    "path": "tools/hg-subrepo/install",
    "content": "#!/usr/bin/env bash\n\nset -e\n\nCONFIG=\"install.conf.yaml\"\nDOTBOT_DIR=\"dotbot\"\n\nDOTBOT_BIN=\"bin/dotbot\"\nBASEDIR=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)\"\n\ncd \"${BASEDIR}\"\n\n(cd \"${DOTBOT_DIR}\" && git submodule update --init --recursive)\n\"${BASEDIR}/${DOTBOT_DIR}/${DOTBOT_BIN}\" -d \"${BASEDIR}\" -c \"${CONFIG}\" \"${@}\"\n"
  },
  {
    "path": "tools/hg-subrepo/install.ps1",
    "content": "$ErrorActionPreference = \"Stop\"\n\n$CONFIG = \"install.conf.yaml\"\n$DOTBOT_DIR = \"dotbot\"\n\n$DOTBOT_BIN = \"bin/dotbot\"\n$BASEDIR = $PSScriptRoot\n\nSet-Location $BASEDIR\n\nSet-Location $DOTBOT_DIR && git submodule update --init --recursive\nforeach ($PYTHON in ('python', 'python3')) {\n    # Python redirects to Microsoft Store in Windows 10 when not installed\n    if (& { $ErrorActionPreference = \"SilentlyContinue\"\n            ![string]::IsNullOrEmpty((&$PYTHON -V))\n            $ErrorActionPreference = \"Stop\" }) {\n        &$PYTHON $(Join-Path $BASEDIR -ChildPath $DOTBOT_DIR | Join-Path -ChildPath $DOTBOT_BIN) -d $BASEDIR -c $CONFIG $Args\n        return\n    }\n}\nWrite-Error \"Error: Cannot find Python.\"\n"
  }
]