Full Code of anishathalye/dotbot for AI

master a7fe585f0876 cached
59 files
208.6 KB
52.9k tokens
264 symbols
1 requests
Download .txt
Showing preview only (223K chars total). Download the full file or copy to clipboard to get everything.
Repository: anishathalye/dotbot
Branch: master
Commit: a7fe585f0876
Files: 59
Total size: 208.6 KB

Directory structure:
gitextract_is1qblwr/

├── .ci/
│   └── check-changelog.py
├── .editorconfig
├── .github/
│   ├── dependabot.yaml
│   └── workflows/
│       ├── ci.yml
│       └── release.yml
├── .gitignore
├── .gitmodules
├── CHANGELOG.md
├── CONTRIBUTING.md
├── DEVELOPMENT.md
├── LICENSE.md
├── README.md
├── bin/
│   └── dotbot
├── codecov.yml
├── pyproject.toml
├── src/
│   └── dotbot/
│       ├── __about__.py
│       ├── __init__.py
│       ├── cli.py
│       ├── config.py
│       ├── context.py
│       ├── dispatcher.py
│       ├── messenger/
│       │   ├── __init__.py
│       │   ├── color.py
│       │   ├── level.py
│       │   └── messenger.py
│       ├── plugin.py
│       ├── plugins/
│       │   ├── __init__.py
│       │   ├── clean.py
│       │   ├── create.py
│       │   ├── link.py
│       │   └── shell.py
│       └── util/
│           ├── __init__.py
│           ├── common.py
│           ├── module.py
│           ├── singleton.py
│           └── string.py
├── tests/
│   ├── __init__.py
│   ├── conftest.py
│   ├── dotbot_plugin_context_plugin.py
│   ├── dotbot_plugin_counter.py
│   ├── dotbot_plugin_directory.py
│   ├── dotbot_plugin_dispatcher_no_plugins.py
│   ├── dotbot_plugin_dry_run.py
│   ├── dotbot_plugin_file.py
│   ├── dotbot_plugin_issue_357.py
│   ├── test_bin_dotbot.py
│   ├── test_clean.py
│   ├── test_cli.py
│   ├── test_config.py
│   ├── test_create.py
│   ├── test_link.py
│   ├── test_noop.py
│   ├── test_plugins.py
│   ├── test_shell.py
│   └── test_shim.py
└── tools/
    ├── git-submodule/
    │   ├── install
    │   └── install.ps1
    └── hg-subrepo/
        ├── install
        └── install.ps1

================================================
FILE CONTENTS
================================================

================================================
FILE: .ci/check-changelog.py
================================================
# noqa: INP001


import argparse
import re
import subprocess
import sys
from typing import Set, Tuple


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("--changelog", type=str, help="Path to the changelog file", required=True)
    args = parser.parse_args()
    git_versions = get_git_minor_versions()
    changelog_versions = get_changelog_versions(args.changelog)
    missing_versions = git_versions - changelog_versions
    if missing_versions:
        print("The following versions are missing from the changelog:")  # noqa: T201
        for version in sorted(missing_versions, key=version_to_tuple):
            print(f"- v{version}")  # noqa: T201
        sys.exit(1)
    changelog_extras = changelog_versions - git_versions
    if changelog_extras:
        print("The following versions are in the changelog but not in git tags:")  # noqa: T201
        for version in sorted(changelog_extras, key=version_to_tuple):
            print(f"- v{version}")  # noqa: T201
        sys.exit(1)
    print("All versions are present in the changelog.")  # noqa: T201


def get_git_minor_versions() -> Set[str]:
    """
    Get all major/minor versions (but not patch versions) from git tags.
    """
    tags = subprocess.check_output(["git", "tag", "--list"]).decode().splitlines()  # noqa: S607
    major_minor_re = re.compile(r"^v(\d+\.\d+)\.\d+$")
    versions: Set[str] = set()
    for tag in tags:
        m = major_minor_re.match(tag)
        if m:
            versions.add(m.group(1))
    return versions


def get_changelog_versions(changelog_path: str) -> Set[str]:
    """
    Get all versions listed in the changelog.
    """
    with open(changelog_path) as f:
        changelog = f.read()
    version_re = re.compile(r"^- v(\d+\.\d+)$", re.MULTILINE)
    return {m.group(1) for m in version_re.finditer(changelog)}


def version_to_tuple(version: str) -> Tuple[int, ...]:
    """
    Convert a version string without the "v" prefix to a tuple.
    """
    return tuple(int(i) for i in version.split("."))


if __name__ == "__main__":
    main()


================================================
FILE: .editorconfig
================================================
root = true

[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
indent_style = space
trim_trailing_whitespace = true

[*.py]
indent_size = 4


================================================
FILE: .github/dependabot.yaml
================================================
version: 2
updates:
  - package-ecosystem: "github-actions"
    directory: "/"
    schedule:
      interval: "monthly"
    groups:
      github-actions:
        patterns:
          - "*"


================================================
FILE: .github/workflows/ci.yml
================================================
name: CI
on:
  push:
  pull_request:
  schedule:
    - cron: "0 8 * * 6"
jobs:
  test:
    env:
      PIP_DISABLE_PIP_VERSION_CHECK: 1
    strategy:
      fail-fast: false
      matrix:
        os: ["ubuntu-22.04", "macos-latest", "windows-latest"]
        python:
          - "3.7"
          - "3.8"
          - "3.9"
          - "3.10"
          - "3.11"
          - "3.12"
          - "3.13"
          - "3.14"
          - "pypy-3.10"
        include:
          - python: "3.7"
            hatch_version: "1.16.4"
        exclude:
          - os: "macos-latest"
            python: "3.7"
          - os: "windows-latest"
            python: "3.7"
          - os: "windows-latest"
            python: "pypy-3.10"
    runs-on: ${{ matrix.os }}
    name: "Test: Python ${{ matrix.python }} on ${{ matrix.os }}"
    steps:
      - uses: actions/checkout@v6
        with:
          submodules: recursive
      - uses: pypa/hatch@install
        with:
          version: ${{ matrix.hatch_version || 'latest' }}
      - run: hatch test -v --cover --include python=$(echo ${{ matrix.python }} | tr -d '-') tests
      - run: hatch run coverage:xml
      - uses: codecov/codecov-action@v5
        with:
          fail_ci_if_error: true
          token: ${{ secrets.CODECOV_TOKEN }}
  typecheck:
    name: Type check
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@v6
      - uses: pypa/hatch@install
      - run: hatch run types:check
  fmt:
    name: Format and lint
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@v6
      - uses: pypa/hatch@install
      - run: hatch fmt --check
  changelog:
    name: Check changelog
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@v6
        with:
          fetch-depth: 0
          fetch-tags: true
      - uses: pypa/hatch@install
      - run: hatch run python .ci/check-changelog.py --changelog CHANGELOG.md


================================================
FILE: .github/workflows/release.yml
================================================
name: Release

on:
  push:
    tags:
      - "v[0-9]+.[0-9]+.[0-9]+"

jobs:
  test:
    env:
      PIP_DISABLE_PIP_VERSION_CHECK: 1
    strategy:
      fail-fast: false
      matrix:
        include:
          - os: ubuntu-latest
            platform: linux
            arch: x64
          - os: ubuntu-24.04-arm
            platform: linux
            arch: arm64
          - os: macos-13
            platform: macos
            arch: x64
          - os: macos-latest
            platform: macos
            arch: arm64
          - os: windows-latest
            platform: windows
            arch: x64
          - os: windows-11-arm
            platform: windows
            arch: arm64
    runs-on: ${{ matrix.os }}
    name: "Test: ${{ matrix.platform }}-${{ matrix.arch }}"
    steps:
      - uses: actions/checkout@v6
        with:
          submodules: recursive
      - uses: pypa/hatch@install
      - run: hatch test

  build-binaries:
    needs: test
    strategy:
      matrix:
        include:
          - os: ubuntu-latest
            platform: linux
            arch: x64
          - os: ubuntu-24.04-arm
            platform: linux
            arch: arm64
          - os: macos-13
            platform: macos
            arch: x64
          - os: macos-latest
            platform: macos
            arch: arm64
          - os: windows-latest
            platform: windows
            arch: x64
          - os: windows-11-arm
            platform: windows
            arch: arm64
    runs-on: ${{ matrix.os }}
    name: Build binary for ${{ matrix.platform }}-${{ matrix.arch }}
    steps:
      - uses: actions/checkout@v6
        with:
          submodules: recursive
      - uses: actions/setup-python@v6
        with:
          python-version: "3.11"
      - name: Install dependencies
        run: |
          python -m pip install --upgrade pip
          pip install pyinstaller
          pip install .
      - name: Build binary
        run: |
          pyinstaller --onefile --name dotbot src/dotbot/cli.py
      - name: Package binary
        shell: bash
        run: |
          if [[ "${{ matrix.platform }}" == "windows" ]]; then
            cd dist && powershell Compress-Archive -Path dotbot.exe -DestinationPath dotbot-${{ matrix.platform }}-${{ matrix.arch }}.zip
          else
            cd dist && tar -czf dotbot-${{ matrix.platform }}-${{ matrix.arch }}.tar.gz dotbot
          fi
      - name: Upload binary
        uses: actions/upload-artifact@v5
        with:
          name: dotbot-${{ matrix.platform }}-${{ matrix.arch }}
          path: dist/dotbot-${{ matrix.platform }}-${{ matrix.arch }}.*

  github-release:
    needs: build-binaries
    runs-on: ubuntu-latest
    name: Create GitHub Release
    permissions:
      contents: write
    steps:
      - uses: actions/checkout@v6
      - name: Download all artifacts
        uses: actions/download-artifact@v6
        with:
          path: artifacts
      - name: Create Release
        uses: softprops/action-gh-release@v2
        with:
          files: artifacts/*/dotbot-*
          generate_release_notes: true


================================================
FILE: .gitignore
================================================
*.pyc
dist/
.coverage*


================================================
FILE: .gitmodules
================================================
[submodule "lib/pyyaml"]
	path = lib/pyyaml
	url = https://github.com/yaml/pyyaml
	ignore = dirty


================================================
FILE: CHANGELOG.md
================================================
Note: this changelog only lists feature additions, not bugfixes. For details on those, see the Git history.

- v1.24
    - Add `backup:` option for `link`
- v1.23
    - Switch default output to only show actions taken
    - Add support for `--dry-run`
    - Add ability to specify plugins in config file
- v1.22
    - Add support for creating hardlinks in `link`
    - Add ability to pass multiple config files
- v1.21
    - Drop support for Python 3.6: the minimum version supported is now Python 3.7
- v1.20
    - Drop support for Python 2 and old versions of Python 3: the minimum version supported is now Python 3.6
- v1.19
    - Add `mode:` option for `create`
    - Add `exclude:` option for `link`
- v1.18
    - Add `--only` and `--except` flags
    - Add support to run with `python -m dotbot`
    - Add `--force-color` option
- v1.17
    - Add `canonicalize-path:` option for `link`
- v1.16
    - Add `create` plugin
- v1.15
    - Add `quiet:` option for `shell`
- v1.14
    - Add `if:` option for `link`
- v1.13
    - Add `--no-color` flag
- v1.12
    - Add globbing support to `link`
- v1.11
    - Add force option to `clean` to remove all broken symlinks
- v1.10
    - Update `link` to support shorthand syntax for links
- v1.9
    - Add support for default options for commands
- v1.8
    - Update `link` to be able to create relative links
- v1.7
    - Add support for plugins
- v1.6
    - Update `link` to expand environment variables in paths
- v1.5
    - Update `link` to be able to automatically overwrite broken symlinks
- v1.4
    - Update `shell` to allow for selectively enabling/disabling stdin, stdout, and stderr
- v1.3
    - Add support for YAML format configs
- v1.2
    - Update `link` to be able to force create links (deleting things that were previously there)
    - Update `link` to be able to create parent directories
- v1.1
    - Update `clean` to remove old broken symlinks
- v1.0
    - Initial commit


================================================
FILE: CONTRIBUTING.md
================================================
# Contributing

All 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].

## Feature Requests

Do 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.

If 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.

## Bug Reports

Did something go wrong with Dotbot? Sorry about that! Bug reports are greatly appreciated!

When 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.

## Patches

Want to hack on Dotbot? Awesome!

If 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.

**Patches are generally submitted as pull requests.** Patches are also [accepted over email][email].

Any 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).

See the [Dotbot development guide][development] to learn how to run the tests, type checking, and more.

---

If you have any questions about anything, feel free to [ask][email]!

[issue]: https://github.com/anishathalye/dotbot/issues/new
[issues]: https://github.com/anishathalye/dotbot/issues
[fork]: https://github.com/anishathalye/dotbot/fork
[email]: mailto:me@anishathalye.com
[commit-messages]: http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html
[development]: DEVELOPMENT.md


================================================
FILE: DEVELOPMENT.md
================================================
# Development

Dotbot uses the [Hatch] project manager ([installation instructions][hatch-install]).

Hatch automatically manages dependencies and runs testing, type checking, and other operations in isolated [environments][hatch-environments].

[Hatch]: https://hatch.pypa.io/
[hatch-install]: https://hatch.pypa.io/latest/install/
[hatch-environments]: https://hatch.pypa.io/latest/environment/

## Testing

You can run the tests on your local machine with:

```bash
hatch test
```

The [`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.

[hatch-test]: https://hatch.pypa.io/latest/tutorials/testing/overview/

### Isolation

Dotbot 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:

```bash
docker run -it --rm -v "${PWD}:/dotbot" -w /dotbot python:3.13-bookworm /bin/bash
```

After spawning the container, install Hatch with `pip install hatch`, and then run the tests as described above.

[dotbot-conftest]: tests/conftest.py

## Type checking

You can run the [mypy static type checker][mypy] with:

```bash
hatch run types:check
```

[mypy]: https://mypy-lang.org/

## Formatting and linting

You can run the [Ruff][ruff] formatter and linter with:

```bash
hatch fmt
```

This 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`.

[ruff]: https://github.com/astral-sh/ruff
[fix-safety]: https://docs.astral.sh/ruff/linter/#fix-safety

## Packaging

You can use [`hatch build`][hatch-build] to create build artifacts, a [source distribution ("sdist")][sdist] and a [built distribution ("wheel")][bdist].

You can use [`hatch publish`][hatch-publish] to publish build artifacts to [PyPI][pypi].

[hatch-build]: https://hatch.pypa.io/latest/build/
[sdist]: https://packaging.python.org/en/latest/glossary/#term-Source-Distribution-or-sdist
[bdist]: https://packaging.python.org/en/latest/glossary/#term-Built-Distribution
[hatch-publish]: https://hatch.pypa.io/latest/publish/
[pypi]: https://pypi.org/

## Continuous integration

Testing, type checking, and formatting/linting is [checked in CI][ci].

[ci]: .github/workflows/ci.yml


================================================
FILE: LICENSE.md
================================================
The MIT License (MIT)
=====================

**Copyright (c) Anish Athalye (me@anishathalye.com)**

Permission 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:

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

THE 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.


================================================
FILE: README.md
================================================
# 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/)

Dotbot makes installing your dotfiles as easy as `git clone $url && cd dotfiles && ./install`, even on a freshly installed system!

- [Rationale](#rationale)
- [Getting Started](#getting-started)
- [Installing Your Dotfiles](#installing-your-dotfiles)
- [Configuration](#configuration)
- [Directives](#directives) ([Link](#link), [Create](#create), [Shell](#shell), [Clean](#clean), [Defaults](#defaults))
- [Plugins](#plugins)
- [Command-line Arguments](#command-line-arguments)
- [Wiki][wiki]

---

## Rationale

Dotbot 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.

Dotbot 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 — it doesn't make any attempt to manage your dotfiles.

Dotbot has many [plugins] that extend its functionality, such as:

- Secrets management: [dotbot-age](https://github.com/fcatuhe/dotbot-age), [dotbot-gitcrypt](https://gitlab.com/gnfzdz/dotbot-gitcrypt), …
- 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), …
- OS and application configuration: [crontab-dotbot](https://github.com/fundor333/crontab-dotbot), [dotbot-firefox](https://github.com/kurtmckee/dotbot-firefox), …
- … and [more][plugins]!

See [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.

## Getting started

### Starting fresh?

Great! 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.

### Integrate with existing dotfiles

The following will help you get set up using Dotbot in just a few steps.

You can create an empty configuration file with:

```bash
touch install.conf.yaml
```

If you're using **Git**, you can add Dotbot as a submodule:

```bash
cd ~/.dotfiles # replace with the path to your dotfiles
git init # initialize repository if needed
git submodule add https://github.com/anishathalye/dotbot
git config -f .gitmodules submodule.dotbot.ignore dirty # ignore dirty commits in the submodule
cp dotbot/tools/git-submodule/install .
```

If you're using **Mercurial**, you can add Dotbot as a subrepo:

```bash
cd ~/.dotfiles # replace with the path to your dotfiles
hg init # initialize repository if needed
echo "dotbot = [git]https://github.com/anishathalye/dotbot" > .hgsub
hg add .hgsub
git clone https://github.com/anishathalye/dotbot
cp dotbot/tools/hg-subrepo/install .
```

If 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].

To 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.

Note: 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.

Setting 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.

#### Installation as a command-line program

If 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]:

```bash
uv tool install dotbot
```

Some 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`.

With Dotbot installed as a command-line program on your system, you can invoke Dotbot with `dotbot -c <path to configuration file>`.

### Full example

Here's an example of a complete configuration.

The conventional name for the configuration file is `install.conf.yaml`.

```yaml
- defaults:
    link:
      relink: true

- clean: ['~']

- link:
    ~/.tmux.conf: tmux.conf
    ~/.vim: vim
    ~/.vimrc: vimrc

- create:
    - ~/downloads
    - ~/.vim/undo-history

- shell:
  - [git submodule update --init --recursive, Installing submodules]
```

The 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`.

## Installing Your Dotfiles

To install your dotfiles on a new machine or after updates:

```bash
git clone <your-dotfiles-repo-url> ~/.dotfiles
cd ~/.dotfiles
./install
```

To update an existing installation:

```bash
cd ~/.dotfiles
git pull
./install
```

## Configuration

Dotbot 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.

**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).

Dotbot 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.

When 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.

## Directives

Most Dotbot commands support both a simplified and extended syntax, and they can also be configured via setting [defaults](#defaults).

### Link

Link 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.

#### Format

Link 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.

Link commands support an optional extended configuration. In this type of configuration, instead of specifying targets directly, link names are mapped to extended configuration dictionaries.

| Parameter | Explanation |
| --- | --- |
| `path` | The target for the link (file in dotfiles directory), the same as in the shortcut syntax (default: null, automatic (see below)) |
| `type` | The type of link to create. If specified, must be either `symlink` or `hardlink`. (default: `symlink`) |
| `create` | When true, create parent directories to the link as needed. (default: false) |
| `relink` | Removes the old link if it's a symlink (default: false) |
| `force` | Force removes the old link, file or folder, and forces a new link (default: false) |
| `backup` | Backup existing files/directories if they exist, creating a backup with suffix `.dotbot-backup.{timestamp}` (default: false) |
| `relative` | When creating a symlink, use a relative path to the target. (default: false, absolute links) |
| `canonicalize` | Resolve any symbolic links encountered in the target to symlink to the canonical path (default: true, real paths) |
| `if` | Execute this in your `$SHELL` and only link if it is successful. |
| `ignore-missing` | Do not fail if the target is missing and create the link anyway (default: false) |
| `glob` | Treat `path` as a glob pattern, expanding patterns referenced below, linking all *files* matched. (default: false) |
| `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) |
| `prefix` | Prepend prefix prefix to basename of each file when linked, when `glob` is `true`. (default: '') |

When `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:

| Pattern  | Meaning                            |
|:---------|:-----------------------------------|
| `*`      | matches anything                   |
| `**`     | matches any **file**, recursively  |
| `?`      | matches any single character       |
| `[seq]`  | matches any character in `seq`     |
| `[!seq]` | matches any character not in `seq` |

However, 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/.*`.

When 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"`).

#### Example

```yaml
- link:
    ~/.config/terminator:
      create: true
      path: config/terminator
    ~/.vim: vim
    ~/.vimrc:
      relink: true
      path: vimrc
    ~/.zshrc:
      force: true
      path: zshrc
    ~/.hammerspoon:
      if: '[ `uname` = Darwin ]'
      path: hammerspoon
    ~/.config/:
      glob: true
      path: dotconf/config/**
    ~/:
      glob: true
      path: dotconf/*
      prefix: '.'
```

If 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.

Explicit targets:

```yaml
- link:
    ~/bin/ack: ack
    ~/.vim: vim
    ~/.vimrc:
      relink: true
      path: vimrc
    ~/.zshrc:
      force: true
      path: zshrc
    ~/.config/:
      glob: true
      path: config/*
      relink: true
      exclude: [ config/Code ]
    ~/.config/Code/User/:
      create: true
      glob: true
      path: config/Code/User/*
      relink: true
```

Implicit targets:

```yaml
- link:
    ~/bin/ack:
    ~/.vim:
    ~/.vimrc:
      relink: true
    ~/.zshrc:
      force: true
    ~/.config/:
      glob: true
      path: config/*
      relink: true
      exclude: [ config/Code ]
    ~/.config/Code/User/:
      create: true
      glob: true
      path: config/Code/User/*
      relink: true
```

### Create

Create 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.

#### Format

Create 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.

| Parameter | Explanation |
| --- | --- |
| `mode` | The file mode to use for creating the leaf directory (default: 0777) |

The `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.

#### Example

```yaml
- create:
    - ~/downloads
    - ~/.vim/undo-history
- create:
    ~/.ssh:
      mode: 0700
    ~/projects:
```

### Shell

Shell commands specify shell commands to be run. Shell commands are run in the base directory (that is specified when running the installer).

#### Format

Shell 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.

Another 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.

Shell commands support an extended syntax as well, which provides more fine-grained control.

| Parameter | Explanation |
| --- | --- |
| `command` | The command to be run |
| `description` | A human-readable message describing the command (default: null) |
| `quiet` | Show only the description but not the command in log output (default: false) |
| `stdin` | Allow a command to read from standard input (default: false) |
| `stdout` | Show a command's output from stdout (default: false) |
| `stderr` | Show a command's error output from stderr (default: false) |

Note 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.

#### Example

```yaml
- shell:
  - chsh -s $(which zsh)
  - [chsh -s $(which zsh), Making zsh the default shell]
  -
    command: read var && echo Your variable is $var
    stdin: true
    stdout: true
    description: Reading and printing variable
    quiet: true
  -
    command: read fail
    stderr: true
```

### Clean

Clean 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`.

#### Format

Clean commands are specified as an array of directories to be cleaned.

Clean commands also support an extended configuration syntax.

| Parameter | Explanation |
| --- | --- |
| `force` | Remove dead links even if they don't point to a file inside the dotfiles directory (default: false) |
| `recursive` | Traverse the directory recursively looking for dead links (default: false) |

Note: using the `recursive` option for `~` is not recommended because it will be slow.

#### Example

```yaml
- clean: ['~']

- clean:
    ~/:
      force: true
    ~/.config:
      recursive: true
```

### Defaults

Default 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.

Defaults 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.

#### Format

Defaults are specified as a dictionary mapping action names to settings, which are dictionaries from option names to values.

#### Example

```yaml
- defaults:
    link:
      create: true
      relink: true
```

### Plugins

Dotbot 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.

Plugins 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`.

All 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.

See [here][plugins] for a current list of third-party plugins.

#### Loading plugins via configuration

You can specify plugins in your configuration file as an array of files or directories (containing plugins) to load:

```yaml
- plugins:
    - dotbot-plugins/dotbot-brew/
    - dotbot-plugins/custom_plugin.py
```

Paths specified in the config file are interpreted relative to the _base directory_.

#### Loading plugins via command line

Plugins can also be loaded using the `--plugin` option. You can use this argument multiple times:

```bash
dotbot --plugin dotbot-plugins/dotbot-brew/ --plugin dotbot-plugins/custom_plugin.py ...
```

Paths specified this way are interpreted relative to the _working directory_ where `dotbot` is invoked.

It is recommended that these options are added directly to your `install` script for consistency across installations.

## Command-line arguments

Dotbot 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.

### `--dry-run`

You 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.

### `--only`

You can call `./install --only [list of directives]`, such as `./install --only link`, and Dotbot will only run those sections of the config file.

### `--except`

You 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.

## Wiki

Check out the [Dotbot wiki][wiki] for more information, tips and tricks, user-contributed plugins, and more.

## Contributing

Do you have a feature request, bug report, or patch? Great! See [CONTRIBUTING.md][contributing] for information on what you can do about that.

## License

Copyright (c) Anish Athalye. Released under the MIT License. See [LICENSE.md][license] for details.

[PyPI]: https://pypi.org/project/dotbot/
[uv]: https://github.com/astral-sh/uv
[homebrew-dotbot]: https://formulae.brew.sh/formula/dotbot
[arch-dotbot]: https://aur.archlinux.org/packages/dotbot
[init-dotfiles]: https://github.com/Vaelatern/init-dotfiles
[dotfiles-template]: https://github.com/anishathalye/dotfiles_template
[inspiration]: https://github.com/anishathalye/dotbot/wiki/Users
[windows-symlinks]: https://learn.microsoft.com/en-us/windows/security/threat-protection/security-policy-settings/create-symbolic-links
[json2yaml]: https://www.json2yaml.com/
[plugins]: https://github.com/anishathalye/dotbot/wiki/Plugins
[wiki]: https://github.com/anishathalye/dotbot/wiki
[contributing]: CONTRIBUTING.md
[license]: LICENSE.md


================================================
FILE: bin/dotbot
================================================
#!/usr/bin/env sh

# This is a valid shell script and also a valid Python script. When this file
# is executed as a shell script, it finds a python binary and executes this
# file as a Python script, passing along all of the command line arguments.
# When this file is executed as a Python script, it loads and runs Dotbot. This
# is useful because we don't know the name of the python binary.

''':' # begin python string; this line is interpreted by the shell as `:`
command -v python3 >/dev/null 2>&1 && exec python3 "$0" "$@"
command -v python  >/dev/null 2>&1 && exec python  "$0" "$@"
>&2 echo "error: cannot find python"
exit 1
'''

# python code

import os
import sys

# this file is syntactically valid Python 2; bail out if the interpreter is Python 2
if sys.version_info[0] < 3:
    print('error: this version of Dotbot is not compatible with Python 2:\nhttps://github.com/anishathalye/dotbot/wiki/Troubleshooting#python-2')
    sys.exit(1)
if sys.version_info < (3, 7):
    print('error: this version of Dotbot requires Python 3.7+')
    sys.exit(1)

project_root_directory = os.path.dirname(
    os.path.dirname(os.path.realpath(__file__)))

def inject(lib_path):
    path = os.path.join(project_root_directory, 'lib', lib_path)
    sys.path.insert(0, path)

inject('pyyaml/lib')

if os.path.exists(os.path.join(project_root_directory, 'src', 'dotbot')):
    src_directory = os.path.join(project_root_directory, 'src')
    if src_directory not in sys.path:
        sys.path.insert(0, src_directory)
        os.putenv('PYTHONPATH', src_directory)

import dotbot

dotbot.cli.main()


================================================
FILE: codecov.yml
================================================
coverage:
  status:
    project: off
    patch: off


================================================
FILE: pyproject.toml
================================================
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[project]
name = "dotbot"
authors = [
  { name = "Anish Athalye", email = "me@anishathalye.com" },
]
description = "A tool that bootstraps your dotfiles"
readme = "README.md"
requires-python = ">=3.7"
classifiers = [
  "Development Status :: 5 - Production/Stable",
  "Intended Audience :: Developers",
  "License :: OSI Approved :: MIT License",
  "Programming Language :: Python :: 3",
  "Topic :: Utilities",
]
keywords = ["dotfiles"]
dynamic = ["version"]
dependencies = [
  "PyYAML>=6.0.1,<7",
]

[project.scripts]
dotbot = "dotbot.cli:main"

[project.urls]
homepage = "https://github.com/anishathalye/dotbot"
repository = "https://github.com/anishathalye/dotbot.git"
issues = "https://github.com/anishathalye/dotbot/issues"

[tool.hatch.version]
path = "src/dotbot/__about__.py"

[tool.hatch.build.targets.sdist]
exclude = [
  "lib/",
]

[tool.hatch.envs.default]
installer = "uv"

[[tool.hatch.envs.hatch-test.matrix]]
python = ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.14", "pypy3.9", "pypy3.10"]

# the default configuration for the hatch-test environment
# (https://hatch.pypa.io/latest/config/internal/testing/#dependencies) uses a
# version of coverage[toml] that is incompatible with Python 3.7 to 3.9, so we override
# the test dependencies for those Python versions here
[tool.hatch.envs.hatch-test.overrides]
name."^py(py)?3\\.(7|8|9)$".set-dependencies = [
  "coverage-enable-subprocess",
  "coverage[toml]",
  "pytest",
  "pytest-mock",
  "pytest-randomly",
  "pytest-rerunfailures",
  "pytest-xdist[psutil]",
]

[tool.coverage.run]
omit = [
  "*/tests/*",
  "*/dotfiles/*" # the tests create some .py files in a "dotfiles" directory
]

[tool.hatch.envs.types]
extra-dependencies = [
  "mypy>=1.0.0",
  "pytest",
  "types-PyYAML>=6.0.1,<7",
]

[tool.hatch.envs.types.scripts]
check = "mypy {args:src tests .ci}"

[tool.hatch.envs.coverage]
detached = true
installer = "uv"
dependencies = [
  "coverage[toml]",
]

[tool.hatch.envs.coverage.scripts]
html = "coverage html"
xml = "coverage xml"

[tool.mypy]
strict = true

[tool.ruff]
extend-exclude = [
  "lib/*.py"
]
lint.ignore = [
  "FA100",
]


================================================
FILE: src/dotbot/__about__.py
================================================
__version__ = "1.24.1"


================================================
FILE: src/dotbot/__init__.py
================================================
from dotbot.__about__ import __version__
from dotbot.cli import main
from dotbot.plugin import Plugin

__all__ = ["Plugin", "__version__", "main"]


================================================
FILE: src/dotbot/cli.py
================================================
import os
import subprocess
import sys
from argparse import SUPPRESS, ArgumentParser, RawTextHelpFormatter
from typing import Any, List

import dotbot
from dotbot.config import ConfigReader, ReadingError
from dotbot.dispatcher import Dispatcher, DispatchError
from dotbot.messenger import Level, Messenger
from dotbot.plugins import Clean, Create, Link, Shell
from dotbot.util import module


def add_options(parser: ArgumentParser) -> None:
    parser.add_argument("-Q", "--super-quiet", action="store_true", help=SUPPRESS)  # deprecated
    parser.add_argument("-q", "--quiet", action="store_true", help="suppress most output")
    parser.add_argument(
        "-v",
        "--verbose",
        action="count",
        default=0,
        help="enable verbose output\n"
        "-v: show informational messages\n"
        "-vv: also, set shell commands stderr/stdout to true",
    )
    parser.add_argument("-d", "--base-directory", help="execute commands from within BASE_DIR", metavar="BASE_DIR")
    parser.add_argument(
        "-c", "--config-file", help="run commands given in CONFIG_FILE", metavar="CONFIG_FILE", nargs="+"
    )
    parser.add_argument(
        "-p",
        "--plugin",
        action="append",
        dest="plugins",
        default=[],
        help="load PLUGIN as a plugin",
        metavar="PLUGIN",
    )
    parser.add_argument("--disable-built-in-plugins", action="store_true", help="disable built-in plugins")
    parser.add_argument(
        "--plugin-dir",
        action="append",
        dest="plugin_dirs",
        default=[],
        metavar="PLUGIN_DIR",
        help=SUPPRESS,  # deprecated
    )
    parser.add_argument("--only", nargs="+", help="only run specified directives", metavar="DIRECTIVE")
    parser.add_argument("--except", nargs="+", dest="skip", help="skip specified directives", metavar="DIRECTIVE")
    parser.add_argument("-n", "--dry-run", action="store_true", help="print what would be done, without doing it")
    parser.add_argument("--force-color", dest="force_color", action="store_true", help="force color output")
    parser.add_argument("--no-color", dest="no_color", action="store_true", help="disable color output")
    parser.add_argument("--version", action="store_true", help="show program's version number and exit")
    parser.add_argument(
        "-x",
        "--exit-on-failure",
        dest="exit_on_failure",
        action="store_true",
        help="exit after first failed directive",
    )


def read_config(config_files: List[str]) -> Any:
    reader = ConfigReader(config_files)
    return reader.get_config()


def main() -> None:
    log = Messenger()
    try:
        parser = ArgumentParser(formatter_class=RawTextHelpFormatter)
        add_options(parser)
        options = parser.parse_args()
        if options.version:
            try:
                with open(os.devnull) as devnull:
                    git_hash = subprocess.check_output(
                        ["git", "rev-parse", "HEAD"],  # noqa: S607
                        cwd=os.path.dirname(os.path.abspath(__file__)),
                        stderr=devnull,
                    ).decode("ascii")
                hash_msg = f" (git {git_hash[:10]})"
            except (OSError, subprocess.CalledProcessError):
                hash_msg = ""
            print(f"Dotbot version {dotbot.__version__}{hash_msg}")  # noqa: T201
            sys.exit(0)
        if options.super_quiet or options.quiet:
            log.set_level(Level.WARNING)
        if options.verbose > 0:
            log.set_level(Level.INFO if options.verbose == 1 else Level.DEBUG)

        if options.force_color and options.no_color:
            log.error("`--force-color` and `--no-color` cannot both be provided")
            sys.exit(1)
        elif options.force_color:
            log.use_color(True)
        elif options.no_color:
            log.use_color(False)
        else:
            log.use_color(sys.stdout.isatty())

        plugins = []
        if not options.disable_built_in_plugins:
            plugins.extend([Clean, Create, Link, Shell])
        module.load_plugins(options.plugin_dirs, plugins)  # note, plugin_dirs is deprecated
        module.load_plugins(options.plugins, plugins)

        if not options.config_file:
            log.error("No configuration file specified")
            sys.exit(1)
        tasks = read_config(options.config_file)
        if not tasks:
            log.warning("No tasks given in configuration, no work to do")
        if options.base_directory:
            base_directory = os.path.abspath(options.base_directory)
        else:
            # default to directory of first config file
            base_directory = os.path.dirname(os.path.abspath(options.config_file[0]))
        os.chdir(base_directory)
        dotbot.dispatcher._all_plugins = plugins  # for backwards compatibility, see dispatcher.py  # noqa: SLF001
        dispatcher = Dispatcher(
            base_directory,
            only=options.only,
            skip=options.skip,
            exit_on_failure=options.exit_on_failure,
            options=options,
            plugins=plugins,
        )
        success = dispatcher.dispatch(tasks)
        if success:
            log.info("All tasks executed successfully")
        else:
            msg = "Some tasks were not executed successfully"
            raise DispatchError(msg)  # noqa: TRY301
    except (ReadingError, DispatchError) as e:
        log.error(str(e))  # noqa: TRY400
        sys.exit(1)
    except KeyboardInterrupt:
        log.error("Operation aborted")  # noqa: TRY400
        sys.exit(1)


if __name__ == "__main__":
    main()


================================================
FILE: src/dotbot/config.py
================================================
import json
import os.path
from typing import Any, List

import yaml

from dotbot.util import string


class ConfigReader:
    _config: List[Any]

    def __init__(self, config_file_paths: List[str]):
        self._config = []
        for path in config_file_paths:
            config = self._read(path)
            if config is None:
                continue
            if not isinstance(config, list):
                msg = "Configuration file must be a list of tasks"
                raise ReadingError(msg)
            self._config.extend(config)

    def _read(self, config_file_path: str) -> Any:
        try:
            _, ext = os.path.splitext(config_file_path)
            with open(config_file_path, encoding="utf-8") as fin:
                return json.load(fin) if ext == ".json" else yaml.safe_load(fin)
        except Exception as e:
            msg = string.indent_lines(str(e))
            msg = f"Could not read config file:\n{msg}"
            raise ReadingError(msg) from e

    def get_config(self) -> Any:
        return self._config


class ReadingError(Exception):
    pass


================================================
FILE: src/dotbot/context.py
================================================
import copy
import os
from argparse import Namespace
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Type

if TYPE_CHECKING:
    from dotbot.plugin import Plugin


class Context:
    """
    Contextual data and information for plugins.
    """

    def __init__(
        self, base_directory: str, options: Optional[Namespace] = None, plugins: "Optional[List[Type[Plugin]]]" = None
    ):
        self._base_directory = base_directory
        self._defaults: Dict[str, Any] = {}
        self._options = options if options is not None else Namespace()
        self._plugins = plugins

    def set_base_directory(self, base_directory: str) -> None:
        self._base_directory = base_directory

    def base_directory(self, canonical_path: bool = True) -> str:  # noqa: FBT001, FBT002 # part of established public API
        base_directory = self._base_directory
        if canonical_path:
            base_directory = os.path.realpath(base_directory)
        return base_directory

    def set_defaults(self, defaults: Dict[str, Any]) -> None:
        self._defaults = defaults

    def defaults(self) -> Dict[str, Any]:
        return copy.deepcopy(self._defaults)

    def options(self) -> Namespace:
        return copy.deepcopy(self._options)

    def plugins(self) -> "Optional[List[Type[Plugin]]]":
        # shallow copy is ok here
        return copy.copy(self._plugins)

    def dry_run(self) -> bool:
        return bool(self._options.dry_run)


================================================
FILE: src/dotbot/dispatcher.py
================================================
import os
from argparse import Namespace
from typing import Any, Dict, List, Optional, Type

from dotbot.context import Context
from dotbot.messenger import Messenger
from dotbot.plugin import Plugin
from dotbot.util.module import load_plugins

# Before b5499c7dc5b300462f3ce1c2a3d9b7a76233b39b, Dispatcher auto-loaded all
# plugins, but after that change, plugins are passed in explicitly (and loaded
# in cli.py). There are some plugins that rely on the old Dispatcher behavior,
# so this is a workaround for implementing similar functionality: when
# Dispatcher is constructed without an explicit list of plugins, _all_plugins is
# used instead.
_all_plugins: List[Type[Plugin]] = []  # filled in by cli.py


class Dispatcher:
    def __init__(
        self,
        base_directory: str,
        only: Optional[List[str]] = None,
        skip: Optional[List[str]] = None,
        exit_on_failure: bool = False,  # noqa: FBT001, FBT002 part of established public API
        options: Optional[Namespace] = None,
        plugins: Optional[List[Type[Plugin]]] = None,
    ):
        # if the caller wants no plugins, the caller needs to explicitly pass in
        # plugins=[]
        self._log = Messenger()
        self._setup_context(base_directory, options, plugins)
        if plugins is None:
            plugins = _all_plugins
        self._plugins = [plugin(self._context) for plugin in plugins]
        self._only = only
        self._skip = skip
        self._exit = exit_on_failure
        self._dry_run: bool = options is not None and bool(options.dry_run)

    def _setup_context(
        self, base_directory: str, options: Optional[Namespace], plugins: Optional[List[Type[Plugin]]]
    ) -> None:
        path = os.path.abspath(os.path.expanduser(base_directory))
        if not os.path.exists(path):
            msg = "Nonexistent base directory"
            raise DispatchError(msg)
        self._context = Context(path, options, plugins)

    def dispatch(self, tasks: List[Dict[str, Any]]) -> bool:
        success = True
        for task in tasks:
            for action in task:
                if (
                    (self._only is not None and action not in self._only)
                    or (self._skip is not None and action in self._skip)
                ) and action != "defaults":
                    self._log.info(f"Skipping action {action}")
                    continue
                handled = False
                if action == "defaults":
                    self._context.set_defaults(task[action])  # replace, not update
                    handled = True
                    # keep going, let other plugins handle this if they want
                if action == "plugins":
                    for plugin_path in task[action]:
                        try:
                            # load the new plugins and add them to the list of plugins
                            # this mutates self._context._plugins; we don't add a setter method
                            # to Context because we don't want plugins to call it
                            new_plugins = load_plugins([plugin_path], self._context._plugins)  # noqa: SLF001
                            for plugin_class in new_plugins:
                                self._plugins.append(plugin_class(self._context))
                        except Exception as err:  # noqa: BLE001
                            self._log.warning(f"Failed to load plugin '{plugin_path}'")
                            self._log.debug(str(err))
                            success = False
                    if not success:
                        self._log.error("Some plugins could not be loaded")
                        if self._exit:
                            self._log.error("Action plugins failed")
                            return False
                    handled = True
                    # keep going, let other plugins handle this if they want
                for plugin in self._plugins:
                    if plugin.can_handle(action):
                        if self._dry_run and not plugin.supports_dry_run:
                            self._log.action(f"Skipping dry-run-unaware plugin {plugin.__class__.__name__}")
                            handled = True
                            continue
                        try:
                            local_success = plugin.handle(action, task[action])
                            if not local_success and self._exit:
                                # The action has failed, exit
                                self._log.error(f"Action {action} failed")
                                return False
                            success &= local_success
                            handled = True
                        except Exception as err:  # noqa: BLE001
                            self._log.error(f"An error was encountered while executing action {action}")
                            self._log.debug(str(err))
                            if self._exit:
                                # There was an exception, exit
                                return False
                if not handled:
                    success = False
                    self._log.error(f"Action {action} not handled")
                    if self._exit:
                        # Invalid action exit
                        return False
        return success


class DispatchError(Exception):
    pass


================================================
FILE: src/dotbot/messenger/__init__.py
================================================
from dotbot.messenger.level import Level
from dotbot.messenger.messenger import Messenger

__all__ = ["Level", "Messenger"]


================================================
FILE: src/dotbot/messenger/color.py
================================================
class Color:
    NONE = ""
    RESET = "\033[0m"
    RED = "\033[91m"
    GREEN = "\033[92m"
    YELLOW = "\033[93m"
    BLUE = "\033[94m"
    MAGENTA = "\033[95m"


================================================
FILE: src/dotbot/messenger/level.py
================================================
from enum import Enum
from typing import Any


class Level(Enum):
    NOTSET = 0
    DEBUG = 10
    INFO = 15
    LOWINFO = 15  # Deprecated: use INFO instead  # noqa: PIE796
    ACTION = 20
    WARNING = 30
    ERROR = 40

    def __lt__(self, other: Any) -> bool:
        if not isinstance(other, Level):
            return NotImplemented
        return self.value < other.value

    def __le__(self, other: Any) -> bool:
        if not isinstance(other, Level):
            return NotImplemented
        return self.value <= other.value

    def __gt__(self, other: Any) -> bool:
        if not isinstance(other, Level):
            return NotImplemented
        return self.value > other.value

    def __ge__(self, other: Any) -> bool:
        if not isinstance(other, Level):
            return NotImplemented
        return self.value >= other.value

    def __eq__(self, other: object) -> bool:
        if not isinstance(other, Level):
            return NotImplemented
        return self.value == other.value

    def __hash__(self) -> int:
        return hash(self.value)


================================================
FILE: src/dotbot/messenger/messenger.py
================================================
from dotbot.messenger.color import Color
from dotbot.messenger.level import Level
from dotbot.util.singleton import Singleton


class Messenger(metaclass=Singleton):
    def __init__(self, level: Level = Level.ACTION):
        self.set_level(level)
        self.use_color(True)

    def set_level(self, level: Level) -> None:
        self._level = level

    def use_color(self, yesno: bool) -> None:  # noqa: FBT001
        self._use_color = yesno

    def log(self, level: Level, message: str) -> None:
        if level >= self._level:
            print(f"{self._color(level)}{message}{self._reset()}")  # noqa: T201

    def debug(self, message: str) -> None:
        self.log(Level.DEBUG, message)

    def action(self, message: str) -> None:
        self.log(Level.ACTION, message)

    def info(self, message: str) -> None:
        self.log(Level.INFO, message)

    def lowinfo(self, message: str) -> None:
        """Deprecated: use info() or action() instead."""
        self.info(message)

    def warning(self, message: str) -> None:
        self.log(Level.WARNING, message)

    def error(self, message: str) -> None:
        self.log(Level.ERROR, message)

    def _color(self, level: Level) -> str:
        """
        Get a color (terminal escape sequence) according to a level.
        """
        if not self._use_color or level < Level.DEBUG:
            return ""
        if level < Level.INFO:
            return Color.YELLOW
        if level < Level.ACTION:
            return Color.BLUE
        if level < Level.WARNING:
            return Color.GREEN
        if level < Level.ERROR:
            return Color.MAGENTA
        return Color.RED

    def _reset(self) -> str:
        """
        Get a reset color (terminal escape sequence).
        """
        if not self._use_color:
            return ""
        return Color.RESET


================================================
FILE: src/dotbot/plugin.py
================================================
from typing import Any

from dotbot.context import Context
from dotbot.messenger import Messenger


class Plugin:
    """
    Abstract base class for commands that process directives.
    """

    _context: Context
    _log: Messenger
    supports_dry_run: bool = False  # plugins must explicitly declare support for dry-run mode

    def __init__(self, context: Context):
        self._context = context
        self._log = Messenger()

    def can_handle(self, directive: str) -> bool:
        """
        Returns true if the Plugin can handle the directive.
        """
        raise NotImplementedError

    def handle(self, directive: str, data: Any) -> bool:
        """
        Executes the directive.

        Returns true if the Plugin successfully handled the directive.
        """
        raise NotImplementedError


================================================
FILE: src/dotbot/plugins/__init__.py
================================================
from dotbot.plugins.clean import Clean
from dotbot.plugins.create import Create
from dotbot.plugins.link import Link
from dotbot.plugins.shell import Shell

__all__ = ["Clean", "Create", "Link", "Shell"]


================================================
FILE: src/dotbot/plugins/clean.py
================================================
import os
import sys
from typing import Any

from dotbot.plugin import Plugin
from dotbot.util.common import normslash


class Clean(Plugin):
    """
    Cleans broken symbolic links.
    """

    supports_dry_run = True

    _directive = "clean"

    def can_handle(self, directive: str) -> bool:
        return directive == self._directive

    def handle(self, directive: str, data: Any) -> bool:
        if directive != self._directive:
            msg = f"Clean cannot handle directive {directive}"
            raise ValueError(msg)
        return self._process_clean(data)

    def _process_clean(self, targets: Any) -> bool:
        success = True
        defaults = self._context.defaults().get(self._directive, {})
        for target in targets:
            force = defaults.get("force", False)
            recursive = defaults.get("recursive", False)
            if isinstance(targets, dict) and isinstance(targets[target], dict):
                force = targets[target].get("force", force)
                recursive = targets[target].get("recursive", recursive)
            success &= self._clean(normslash(target), force=force, recursive=recursive)
        if success:
            self._log.info("All targets have been cleaned")
        else:
            self._log.error("Some targets were not successfully cleaned")
        return success

    def _clean(self, target: str, *, force: bool, recursive: bool) -> bool:
        """
        Cleans all the broken symbolic links in target if they point to
        a subdirectory of the base directory or if forced to clean.
        """
        if not os.path.isdir(os.path.expandvars(os.path.expanduser(target))):
            self._log.debug(f"Ignoring nonexistent directory {target}")
            return True
        for item in os.listdir(os.path.expandvars(os.path.expanduser(target))):
            path = os.path.abspath(os.path.join(os.path.expandvars(os.path.expanduser(target)), item))
            if recursive and os.path.isdir(path):
                # isdir implies not islink -- we don't want to descend into
                # symlinked directories. okay to do a recursive call here
                # because depth should be fairly limited
                self._clean(path, force=force, recursive=recursive)
            if not os.path.exists(path) and os.path.islink(path):
                points_at = os.path.join(os.path.dirname(path), os.readlink(path))
                if sys.platform == "win32" and points_at.startswith("\\\\?\\"):
                    points_at = points_at[4:]
                if self._in_directory(path, self._context.base_directory()) or force:
                    if self._context.dry_run():
                        self._log.action(f"Would remove invalid link {path} -> {points_at}")
                    else:
                        self._log.action(f"Removing invalid link {path} -> {points_at}")
                        os.remove(path)
                else:
                    self._log.info(f"Link {path} -> {points_at} not removed.")
        return True

    def _in_directory(self, path: str, directory: str) -> bool:
        """
        Returns true if the path is in the directory.
        """
        directory = os.path.join(os.path.realpath(directory), "")
        path = os.path.realpath(path)
        return os.path.commonprefix([path, directory]) == directory


================================================
FILE: src/dotbot/plugins/create.py
================================================
import os
from typing import Any

from dotbot.plugin import Plugin
from dotbot.util.common import normslash


class Create(Plugin):
    """
    Create empty paths.
    """

    supports_dry_run = True

    _directive = "create"

    def can_handle(self, directive: str) -> bool:
        return directive == self._directive

    def handle(self, directive: str, data: Any) -> bool:
        if directive != self._directive:
            msg = f"Create cannot handle directive {directive}"
            raise ValueError(msg)
        return self._process_paths(data)

    def _process_paths(self, paths: Any) -> bool:
        success = True
        defaults = self._context.defaults().get("create", {})
        for key in paths:
            path = os.path.abspath(os.path.expandvars(os.path.expanduser(normslash(key))))
            mode = defaults.get("mode", 0o777)  # same as the default for os.makedirs
            if isinstance(paths, dict):
                options = paths[key]
                if options:
                    mode = options.get("mode", mode)
            success &= self._create(path, mode)
        if success:
            self._log.info("All paths have been set up")
        else:
            self._log.error("Some paths were not successfully set up")
        return success

    def _exists(self, path: str) -> bool:
        """
        Returns true if the path exists.
        """
        path = os.path.expanduser(path)
        return os.path.exists(path)

    def _create(self, path: str, mode: int) -> bool:
        success = True
        if not self._exists(path):
            self._log.debug(f"Trying to create path {path} with mode {mode}")
            try:
                if self._context.dry_run():
                    self._log.action(f"Would create path {path}")
                    return True
                self._log.action(f"Creating path {path}")
                os.makedirs(path, mode)
                # On Windows, the *mode* argument to `os.makedirs()` is ignored.
                # The mode must be set explicitly in a follow-up call.
                os.chmod(path, mode)
            except OSError:
                self._log.warning(f"Failed to create path {path}")
                success = False
        else:
            self._log.info(f"Path exists {path}")
        return success


================================================
FILE: src/dotbot/plugins/link.py
================================================
import glob
import os
import shutil
import sys
from datetime import datetime, timezone
from typing import Any, List, Optional, Tuple

from dotbot.plugin import Plugin
from dotbot.util import shell_command
from dotbot.util.common import normslash


class Link(Plugin):
    """
    Symbolically links dotfiles.
    """

    supports_dry_run = True

    _directive = "link"

    def can_handle(self, directive: str) -> bool:
        return directive == self._directive

    def handle(self, directive: str, data: Any) -> bool:
        if directive != self._directive:
            msg = f"Link cannot handle directive {directive}"
            raise ValueError(msg)
        return self._process_links(data)

    def _process_links(self, links: Any) -> bool:
        success = True
        defaults = self._context.defaults().get("link", {})

        # Validate the default link type before looping.
        link_type = defaults.get("type", "symlink")
        if link_type not in {"symlink", "hardlink"}:
            self._log.warning(f"The default link type is not recognized: '{link_type}'")
            return False

        for link_name, target in links.items():
            link_name = os.path.expandvars(normslash(link_name))  # noqa: PLW2901
            relative = defaults.get("relative", False)
            # support old "canonicalize-path" key for compatibility
            canonical_path = defaults.get("canonicalize", defaults.get("canonicalize-path", True))
            link_type = defaults.get("type", "symlink")
            force = defaults.get("force", False)
            relink = defaults.get("relink", False)
            create = defaults.get("create", False)
            use_glob = defaults.get("glob", False)
            backup = defaults.get("backup", False)
            base_prefix = defaults.get("prefix", "")
            test = defaults.get("if", None)
            ignore_missing = defaults.get("ignore-missing", False)
            exclude_paths = defaults.get("exclude", [])
            if isinstance(target, dict):
                # extended config
                test = target.get("if", test)
                relative = target.get("relative", relative)
                canonical_path = target.get("canonicalize", target.get("canonicalize-path", canonical_path))
                link_type = target.get("type", link_type)
                if link_type not in {"symlink", "hardlink"}:
                    msg = f"The link type is not recognized: '{link_type}'"
                    self._log.warning(msg)
                    success = False
                    continue
                force = target.get("force", force)
                relink = target.get("relink", relink)
                create = target.get("create", create)
                use_glob = target.get("glob", use_glob)
                backup = target.get("backup", backup)
                base_prefix = target.get("prefix", base_prefix)
                ignore_missing = target.get("ignore-missing", ignore_missing)
                exclude_paths = target.get("exclude", exclude_paths)
                path = self._default_target(link_name, target.get("path"))
            else:
                path = self._default_target(link_name, target)
            path = normslash(path)
            if test is not None and not self._test_success(test):
                self._log.info(f"Skipping {link_name}")
                continue
            path = os.path.normpath(os.path.expandvars(os.path.expanduser(path)))
            if use_glob and self._has_glob_chars(path):
                glob_results = self._create_glob_results(path, exclude_paths)
                self._log.debug(f"Globs from '{path}': {glob_results}")
                for glob_full_item in glob_results:
                    # Find common dirname between pattern and the item:
                    glob_dirname = os.path.dirname(os.path.commonprefix([path, glob_full_item]))
                    glob_item = glob_full_item if len(glob_dirname) == 0 else glob_full_item[len(glob_dirname) + 1 :]
                    # Add prefix to basepath, if provided
                    if base_prefix:
                        glob_item = base_prefix + glob_item
                    # where is it going
                    glob_link_name = os.path.join(link_name, glob_item)
                    if create:
                        success &= self._create(glob_link_name)
                    did_backup = False
                    did_delete = False
                    if backup:
                        did_backup, backup_success = self._backup(glob_link_name)
                        success &= backup_success
                    # we only need to consider force/relink if we didn't do a backup
                    if (force or relink) and not (did_backup and backup_success):
                        did_delete, delete_success = self._delete(
                            glob_full_item,
                            glob_link_name,
                            relative=relative,
                            canonical_path=canonical_path,
                            force=force,
                        )
                        success &= delete_success
                    success &= self._link(
                        glob_full_item,
                        glob_link_name,
                        relative=relative,
                        canonical_path=canonical_path,
                        ignore_missing=ignore_missing,
                        link_type=link_type,
                        assume_gone=(did_backup or did_delete),
                    )
            else:
                if create:
                    success &= self._create(link_name)
                if not ignore_missing and not self._exists(os.path.join(self._context.base_directory(), path)):
                    # we seemingly check this twice (here and in _link) because
                    # if the file doesn't exist and force is True, we don't
                    # want to remove the original (this is tested by test_link_force_leaves_when_nonexistent)
                    success = False
                    self._log.warning(f"Nonexistent target {link_name} -> {path}")
                    continue
                did_backup = False
                did_delete = False
                if backup:
                    did_backup, backup_success = self._backup(link_name)
                    success &= backup_success
                # we only need to consider force/relink if we didn't do a backup
                if (force or relink) and not (did_backup and backup_success):
                    did_delete, delete_success = self._delete(
                        path, link_name, relative=relative, canonical_path=canonical_path, force=force
                    )
                    success &= delete_success
                success &= self._link(
                    path,
                    link_name,
                    relative=relative,
                    canonical_path=canonical_path,
                    ignore_missing=ignore_missing,
                    link_type=link_type,
                    assume_gone=(did_backup or did_delete),
                )
        if success:
            self._log.info("All links have been set up")
        else:
            self._log.error("Some links were not successfully set up")
        return success

    def _test_success(self, command: str) -> bool:
        ret = shell_command(command, cwd=self._context.base_directory())
        if ret != 0:
            self._log.debug(f"Test '{command}' returned false")
        return ret == 0

    def _default_target(self, link_name: str, target: Optional[str]) -> str:
        if target is None:
            basename = os.path.basename(link_name)
            if basename.startswith("."):
                return basename[1:]
            return basename
        return target

    def _has_glob_chars(self, path: str) -> bool:
        return any(i in path for i in "?*[")

    def _glob(self, path: str) -> List[str]:
        """
        Wrap `glob.glob` in a python agnostic way, catching errors in usage.
        """
        found = glob.glob(path, recursive=True)
        # normalize paths to ensure cross-platform compatibility
        found = [os.path.normpath(p) for p in found]
        # if using recursive glob (`**`), filter results to return only files:
        if "**" in path and not path.endswith(str(os.sep)):
            self._log.debug("Excluding directories from recursive glob: " + str(path))
            found = [f for f in found if os.path.isfile(f)]
        # return matched results
        return found

    def _create_glob_results(self, path: str, exclude_paths: List[str]) -> List[str]:
        self._log.debug("Globbing with pattern: " + str(path))
        include = self._glob(path)
        self._log.debug("Glob found : " + str(include))
        # filter out any paths matching the exclude globs:
        exclude = []
        for expat in exclude_paths:
            self._log.debug("Excluding globs with pattern: " + str(expat))
            exclude.extend(self._glob(expat))
        self._log.debug("Excluded globs from '" + path + "': " + str(exclude))
        ret = set(include) - set(exclude)
        return list(ret)

    def _is_link(self, path: str) -> bool:
        """
        Returns true if the path is a symbolic link.
        """
        return os.path.islink(os.path.expanduser(path))

    def _link_target(self, path: str) -> str:
        """
        Returns the target of the symbolic link.
        """
        path = os.path.expanduser(path)
        path = os.readlink(path)
        if sys.platform == "win32" and path.startswith("\\\\?\\"):
            path = path[4:]
        return path

    def _exists(self, path: str) -> bool:
        """
        Returns true if the path exists.
        """
        path = os.path.expanduser(path)
        return os.path.exists(path)

    def _lexists(self, path: str) -> bool:
        """
        Returns true if the path exists (including broken symlinks).
        """
        path = os.path.expanduser(path)
        return os.path.lexists(path)

    def _create(self, path: str) -> bool:
        success = True
        parent = os.path.abspath(os.path.join(os.path.expanduser(path), os.pardir))
        if not self._exists(parent):
            self._log.debug(f"Try to create parent: {parent}")
            if self._context.dry_run():
                self._log.action(f"Would create directory {parent}")
                return True
            try:
                os.makedirs(parent)
            except OSError as e:
                self._log.warning(f"Failed to create directory {parent}")
                self._log.debug(f"OSError: {e!s}")
                success = False
            else:
                self._log.action(f"Creating directory {parent}")
        return success

    def _backup(self, path: str) -> Tuple[bool, bool]:
        if self._exists(path) and not self._is_link(path):
            timestamp = datetime.now(timezone.utc).astimezone().strftime("%Y%m%d-%H%M%S")
            backup_name = f"{path}.dotbot-backup.{timestamp}"
            self._log.debug(f"Try to backup file {path} to {backup_name}")
            if self._context.dry_run():
                self._log.action(f"Would backup {path} to {backup_name}")
                return True, True
            try:
                os.rename(os.path.abspath(os.path.expanduser(path)), os.path.abspath(os.path.expanduser(backup_name)))
            except OSError as e:
                self._log.warning(f"Failed to backup file {path} to {backup_name}")
                self._log.debug(f"OSError: {e!s}")
                return False, False
            else:
                self._log.action(f"Backed up file {path} to {backup_name}")
                return True, True
        return False, True

    def _delete(
        self, target: str, path: str, *, relative: bool, canonical_path: bool, force: bool
    ) -> Tuple[bool, bool]:
        success = True
        removed = False
        target = os.path.join(self._context.base_directory(canonical_path=canonical_path), target)
        fullpath = os.path.abspath(os.path.expanduser(path))
        if self._exists(path) and not self._is_link(path) and os.path.realpath(fullpath) == target:
            # Special case: The path is not a symlink but resolves to the target anyway.
            # Deleting the path would actually delete the target.
            # This may happen if a parent directory is a symlink.
            self._log.warning(f"{path} appears to be the same file as {target}.")
            return False, False
        if relative:
            target = self._relative_path(target, fullpath)
        if (self._is_link(path) and self._link_target(path) != target) or (
            self._lexists(path) and not self._is_link(path)
        ):
            if self._context.dry_run():
                self._log.action(f"Would remove {path}")
                removed = True
            else:
                try:
                    if os.path.islink(fullpath):
                        os.unlink(fullpath)
                        removed = True
                    elif force:
                        if os.path.isdir(fullpath):
                            shutil.rmtree(fullpath)
                            removed = True
                        else:
                            os.remove(fullpath)
                            removed = True
                except OSError as e:
                    self._log.warning(f"Failed to remove {path}")
                    self._log.debug(f"OSError: {e!s}")
                    success = False
                else:
                    if removed:
                        self._log.action(f"Removing {path}")
        return removed, success

    def _relative_path(self, target: str, link_name: str) -> str:
        """
        Returns the relative path to get to the target file from the
        link location.
        """
        link_dir = os.path.dirname(link_name)
        return os.path.relpath(target, link_dir)

    def _link(
        self,
        target: str,
        link_name: str,
        *,
        relative: bool,
        canonical_path: bool,
        ignore_missing: bool,
        link_type: str,
        assume_gone: bool,
    ) -> bool:
        """
        Links link_name to target.

        The caller must ensure that the target exists.

        Returns true if successfully linked files.
        """

        link_path = os.path.abspath(os.path.expanduser(link_name))
        base_directory = self._context.base_directory(canonical_path=canonical_path)
        absolute_target = os.path.join(base_directory, target)
        link_name = os.path.normpath(link_name)
        target_path = self._relative_path(absolute_target, link_path) if relative else absolute_target

        # we need to use absolute_target below because our cwd is the dotfiles
        # directory, and if target_path is relative, it will be relative to the
        # link directory
        if ((not self._lexists(link_name)) or (self._context.dry_run() and assume_gone)) and (
            ignore_missing or self._exists(absolute_target)
        ):
            if self._context.dry_run():
                self._log.action(f"Would create {link_type} {link_name} -> {target_path}")
                return True
            try:
                if link_type == "symlink":
                    os.symlink(target_path, link_path)
                else:  # link_type == "hardlink"
                    os.link(absolute_target, link_path)
            except OSError as e:
                self._log.warning(f"Linking failed {link_name} -> {target_path}")
                self._log.debug(f"OSError: {e!s}")
                return False
            else:
                self._log.action(f"Creating {link_type} {link_name} -> {target_path}")
                return True

        # Failure case: The link name exists and is a symlink
        if self._is_link(link_name):
            if link_type == "symlink":
                if self._link_target(link_name) == target_path:
                    # Idempotent case: The configured symlink already exists
                    self._log.info(f"Link exists {link_name} -> {target_path}")
                    return True

                # The existing symlink isn't pointing at the target.
                # Distinguish between an incorrect symlink and a broken ("invalid") symlink.
                terminology = "Incorrect" if self._exists(link_name) else "Invalid"
                self._log.warning(f"{terminology} link {link_name} -> {self._link_target(link_name)}")
                return False

            self._log.warning(f"{link_name} already exists but is a symbolic link, not a hard link")
            return False

        # Failure case: The link name exists
        if link_type == "hardlink" and os.stat(link_path).st_ino == os.stat(absolute_target).st_ino:
            # Idempotent case: The configured hardlink already exists
            self._log.info(f"Link exists {link_name} -> {target_path}")
            return True

        self._log.warning(f"{link_name} already exists but is a regular file or directory")
        return False


================================================
FILE: src/dotbot/plugins/shell.py
================================================
from typing import Any, Dict

from dotbot.plugin import Plugin
from dotbot.util.common import shell_command


class Shell(Plugin):
    """
    Run arbitrary shell commands.
    """

    supports_dry_run = True

    _directive = "shell"
    _has_shown_override_message = False

    def can_handle(self, directive: str) -> bool:
        return directive == self._directive

    def handle(self, directive: str, data: Any) -> bool:
        if directive != self._directive:
            msg = f"Shell cannot handle directive {directive}"
            raise ValueError(msg)
        return self._process_commands(data)

    def _process_commands(self, data: Any) -> bool:
        success = True
        defaults = self._context.defaults().get("shell", {})
        options = self._get_option_overrides()
        for item in data:
            stdin = defaults.get("stdin", False)
            stdout = defaults.get("stdout", False)
            stderr = defaults.get("stderr", False)
            quiet = defaults.get("quiet", False)
            if isinstance(item, dict):
                cmd = item["command"]
                msg = item.get("description", None)
                stdin = item.get("stdin", stdin)
                stdout = item.get("stdout", stdout)
                stderr = item.get("stderr", stderr)
                quiet = item.get("quiet", quiet)
            elif isinstance(item, list):
                cmd = item[0]
                msg = item[1] if len(item) > 1 else None
            else:
                cmd = item
                msg = None
            prefix = "Would run command " if self._context.dry_run() else ""
            if quiet:
                if msg is not None:
                    self._log.info(f"{prefix}{msg}")
                # if quiet and no msg, show nothing
            elif msg is None:
                self._log.action(f"{prefix}{cmd}")
            else:
                self._log.action(f"{prefix}{msg} [{cmd}]")
            if self._context.dry_run():
                continue
            stdout = options.get("stdout", stdout)
            stderr = options.get("stderr", stderr)
            ret = shell_command(
                cmd,
                cwd=self._context.base_directory(),
                enable_stdin=stdin,
                enable_stdout=stdout,
                enable_stderr=stderr,
            )
            if ret != 0:
                success = False
                self._log.warning(f"Command [{cmd}] failed")
        if success:
            self._log.info("All commands have been executed")
        else:
            self._log.error("Some commands were not successfully executed")
        return success

    def _get_option_overrides(self) -> Dict[str, bool]:
        ret = {}
        options = self._context.options()
        if options.verbose > 1:
            ret["stderr"] = True
            ret["stdout"] = True
            if not self._has_shown_override_message:
                self._log.debug("Shell: Found cli option to force show stderr and stdout.")
                self._has_shown_override_message = True
        return ret


================================================
FILE: src/dotbot/util/__init__.py
================================================
from dotbot.util.common import shell_command

__all__ = ["shell_command"]


================================================
FILE: src/dotbot/util/common.py
================================================
import os
import platform
import subprocess
import sys
from typing import Optional


def shell_command(
    command: str,
    cwd: Optional[str] = None,
    *,
    enable_stdin: bool = False,
    enable_stdout: bool = False,
    enable_stderr: bool = False,
) -> int:
    with open(os.devnull, "w") as devnull_w, open(os.devnull) as devnull_r:
        stdin = None if enable_stdin else devnull_r
        stdout = None if enable_stdout else devnull_w
        stderr = None if enable_stderr else devnull_w
        executable = os.environ.get("SHELL")
        if platform.system() == "Windows":
            # We avoid setting the executable kwarg on Windows because it does
            # not have the desired effect when combined with shell=True. It
            # will result in the correct program being run (e.g. bash), but it
            # will be invoked with a '/c' argument instead of a '-c' argument,
            # which it won't understand.
            #
            # See https://github.com/anishathalye/dotbot/issues/219 and
            # https://bugs.python.org/issue40467.
            #
            # This means that complex commands that require Bash's parsing
            # won't work; a workaround for this is to write the command as
            # `bash -c "..."`.
            executable = None
        return subprocess.call(  # noqa: S602
            command,
            shell=True,
            executable=executable,
            stdin=stdin,
            stdout=stdout,
            stderr=stderr,
            cwd=cwd,
        )


def normslash(path: str) -> str:
    if sys.platform == "win32":
        # this is how normcase in cpython/Lib/ntpath.py does it; we don't use normcase
        # because we don't want to make all characters lowercase
        return path.replace("/", "\\")
    return path


================================================
FILE: src/dotbot/util/module.py
================================================
import glob
import importlib.util
import os
from types import ModuleType
from typing import List, Optional, Type

from dotbot.plugin import Plugin

# We keep references to loaded modules so they don't get garbage collected.
loaded_modules = []


def load(path: str) -> List[Type[Plugin]]:
    basename = os.path.basename(path)
    module_name, _ = os.path.splitext(basename)
    loaded_module = load_module(module_name, path)
    plugins = []
    for name in dir(loaded_module):
        possible_plugin = getattr(loaded_module, name)
        try:
            if issubclass(possible_plugin, Plugin) and possible_plugin is not Plugin:
                plugins.append(possible_plugin)
        except TypeError:
            pass
    loaded_modules.append(loaded_module)
    return plugins


def load_module(module_name: str, path: str) -> ModuleType:
    spec = importlib.util.spec_from_file_location(module_name, path)
    if not spec or not spec.loader:
        msg = f"Unable to load module {module_name} from {path}"
        raise ImportError(msg)
    module = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(module)
    return module


def load_plugins(paths: List[str], plugins: Optional[List[Type[Plugin]]] = None) -> List[Type[Plugin]]:
    """
    Load plugins from the given paths and add them to the given list of plugins.

    Args:
        paths: List of file paths to load plugins from. Each path can be either a file or a directory.
        plugins: List of existing plugins to add to.

    Returns the newly-loaded plugins.
    """
    if plugins is None:
        plugins = []
    new_plugins = []
    plugin_paths = []
    for path in paths:
        if os.path.isdir(path):
            plugin_paths.extend(glob.glob(os.path.join(path, "*.py")))
        else:
            plugin_paths.append(path)
    for plugin_path in plugin_paths:
        abspath = os.path.abspath(plugin_path)
        for plugin in load(abspath):
            # ensure plugins are unique to avoid duplicate execution, which
            # can happen if, for example, a third-party plugin loads a
            # built-in plugin, which will cause it to appear in the list
            # returned by load() above
            plugin_already_loaded = any(
                existing_plugin.__module__ == plugin.__module__ and existing_plugin.__name__ == plugin.__name__
                for existing_plugin in plugins
            )
            if not plugin_already_loaded:
                plugins.append(plugin)
                new_plugins.append(plugin)
    return new_plugins


================================================
FILE: src/dotbot/util/singleton.py
================================================
from typing import Any


class Singleton(type):
    def __call__(cls, *args: Any, **kwargs: Any) -> Any:
        if not hasattr(cls, "_singleton_instance"):
            cls._singleton_instance = super().__call__(*args, **kwargs)
        return cls._singleton_instance

    def reset_instance(cls) -> None:
        if hasattr(cls, "_singleton_instance"):
            del cls._singleton_instance


================================================
FILE: src/dotbot/util/string.py
================================================
def indent_lines(string: str, amount: int = 2, delimiter: str = "\n") -> str:
    whitespace = " " * amount
    sep = f"{delimiter}{whitespace}"
    return f"{whitespace}{sep.join(string.split(delimiter))}"


================================================
FILE: tests/__init__.py
================================================


================================================
FILE: tests/conftest.py
================================================
import builtins
import ctypes
import json
import os
import shutil
import sys
import tempfile
from shutil import rmtree
from typing import Any, Callable, Generator, List, Optional
from unittest import mock

import pytest
import yaml

import dotbot.cli


def get_long_path(path: str) -> str:
    """Get the long path for a given path."""

    # Do nothing for non-Windows platforms.
    if sys.platform != "win32":
        return path

    buffer_size = 1000
    buffer = ctypes.create_unicode_buffer(buffer_size)
    get_long_path_name = ctypes.windll.kernel32.GetLongPathNameW
    get_long_path_name(path, buffer, buffer_size)
    return buffer.value


# On Linux, tempfile.TemporaryFile() requires unlink access.
# This list is updated by a tempfile._mkstemp_inner() wrapper,
# and its contents are checked by wrapped functions.
allowed_tempfile_internal_unlink_calls: List[str] = []


def get_path_from_fd(fd: int) -> Optional[str]:
    """Get the filesystem path for a file descriptor.

    Returns None if the path cannot be determined (e.g., on Windows or if fd is invalid).
    """
    try:
        if sys.platform == "linux":
            return os.readlink(f"/proc/self/fd/{fd}")
        if sys.platform == "darwin":
            import fcntl  # noqa: PLC0415

            f_getpath = getattr(fcntl, "F_GETPATH", 50)  # Python 3.9+ exposes fcntl.F_GETPATH
            path_buf = b"\0" * 1024
            result = fcntl.fcntl(fd, f_getpath, path_buf)
            return result.rstrip(b"\0").decode("utf-8")
    except (OSError, ValueError):
        return None
    else:
        # Windows doesn't have an easy way to get the path from an fd
        return None


def wrap_function(
    function: Callable[..., Any], function_path: str, arg_index: int, kwarg_key: str, root: str
) -> Callable[..., Any]:
    is_unlink = function == os.unlink

    def wrapper(*args: Any, **kwargs: Any) -> Any:
        value = kwargs[kwarg_key] if kwarg_key in kwargs else args[arg_index]

        # Allow tempfile.TemporaryFile's internal unlink calls to work.
        if value in allowed_tempfile_internal_unlink_calls:
            return function(*args, **kwargs)

        # For unlink(), allow relative paths when dir_fd is provided (used by shutil.rmtree)
        if is_unlink and "dir_fd" in kwargs and kwargs["dir_fd"] is not None:
            dir_fd = kwargs["dir_fd"]
            dir_path = get_path_from_fd(dir_fd)

            if sys.platform in {"linux", "darwin"}:
                msg = f"Failed to resolve dir_fd to path for {function_path}()"
                assert dir_path is not None, msg

                msg = f"The dir_fd argument to {function_path}() must point to a directory rooted in {root}"
                assert dir_path[: len(str(root))] == str(root), msg
            # On Windows, we can't easily validate dir_fd, but it's reasonably safe because the dir_fd typically comes
            # from opening a path we already validated (e.g., from shutil.rmtree), and this is test infrastructure with
            # trusted (non-adversarial) code.

            return function(*args, **kwargs)

        msg = f"The '{kwarg_key}' argument to {function_path}() must be an absolute path"
        assert value == os.path.abspath(value), msg

        msg = f"The '{kwarg_key}' argument to {function_path}() must be rooted in {root}"
        assert value[: len(str(root))] == str(root), msg

        return function(*args, **kwargs)

    return wrapper


def wrap_open(root: str) -> Callable[..., Any]:
    wrapped = builtins.open

    def wrapper(*args: Any, **kwargs: Any) -> Any:
        value = kwargs["file"] if "file" in kwargs else args[0]

        mode = "r"
        if "mode" in kwargs:
            mode = kwargs["mode"]
        elif len(args) >= 2:
            mode = args[1]

        msg = "The 'file' argument to open() must be an absolute path"
        if value != os.devnull and "w" in mode:
            assert value == os.path.abspath(value), msg

        msg = f"The 'file' argument to open() must be rooted in {root}"
        if value != os.devnull and "w" in mode:
            assert value[: len(str(root))] == str(root), msg

        return wrapped(*args, **kwargs)

    return wrapper


def rmtree_error_handler(_function: Any, path: str, _excinfo: Any) -> None:
    # Handle read-only files and directories.
    os.chmod(path, 0o777)
    if os.path.isdir(path):
        rmtree(path)
    else:
        os.unlink(path)


@pytest.fixture(autouse=True, scope="session")
def standardize_tmp() -> None:
    r"""Standardize the temporary directory path.

    On MacOS, `/var` is a symlink to `/private/var`.
    This creates issues with link canonicalization and relative link tests,
    so this fixture rewrites environment variables and Python variables
    to ensure the tests work the same as on Linux and Windows.

    On Windows in GitHub CI, the temporary directory may be a short path.
    For example, `C:\Users\RUNNER~1\...` instead of `C:\Users\runneradmin\...`.
    This causes string-based path comparisons to fail.
    """

    tmp = tempfile.gettempdir()
    # MacOS: `/var` is a symlink.
    tmp = os.path.abspath(os.path.realpath(tmp))
    # Windows: The temporary directory may be a short path.
    if sys.platform == "win32":
        tmp = get_long_path(tmp)
    os.environ["TMP"] = tmp
    os.environ["TEMP"] = tmp
    os.environ["TMPDIR"] = tmp
    tempfile.tempdir = tmp


@pytest.fixture(autouse=True)
def root(standardize_tmp: None) -> Generator[str, None, None]:
    _ = standardize_tmp
    """Create a temporary directory for the duration of each test."""

    # Reset allowed_tempfile_internal_unlink_calls.
    global allowed_tempfile_internal_unlink_calls  # noqa: PLW0603
    allowed_tempfile_internal_unlink_calls = []

    # Dotbot changes the current working directory,
    # so this must be reset at the end of each test.
    current_working_directory = os.getcwd()

    # Create an isolated temporary directory from which to operate.
    current_root = tempfile.mkdtemp()

    functions_to_wrap = [
        (os, "chflags", 0, "path"),
        (os, "chmod", 0, "path"),
        (os, "chown", 0, "path"),
        (os, "lchflags", 0, "path"),
        (os, "lchmod", 0, "path"),
        (os, "link", 1, "dst"),
        (os, "makedirs", 0, "name"),
        (os, "mkdir", 0, "path"),
        (os, "mkfifo", 0, "path"),
        (os, "mknod", 0, "path"),
        (os, "remove", 0, "path"),
        (os, "removedirs", 0, "name"),
        (os, "removexattr", 0, "path"),
        (os, "rename", 0, "src"),  # Check both
        (os, "rename", 1, "dst"),
        (os, "renames", 0, "old"),  # Check both
        (os, "renames", 1, "new"),
        (os, "replace", 0, "src"),  # Check both
        (os, "replace", 1, "dst"),
        (os, "rmdir", 0, "path"),
        (os, "setxattr", 0, "path"),
        (os, "splice", 1, "dst"),
        (os, "symlink", 1, "dst"),
        (os, "truncate", 0, "path"),
        (os, "unlink", 0, "path"),
        (os, "utime", 0, "path"),
        (shutil, "chown", 0, "path"),
        (shutil, "copy", 1, "dst"),
        (shutil, "copy2", 1, "dst"),
        (shutil, "copyfile", 1, "dst"),
        (shutil, "copymode", 1, "dst"),
        (shutil, "copystat", 1, "dst"),
        (shutil, "copytree", 1, "dst"),
        (shutil, "make_archive", 0, "base_name"),
        (shutil, "move", 0, "src"),  # Check both
        (shutil, "move", 1, "dst"),
        (shutil, "rmtree", 0, "path"),
        (shutil, "unpack_archive", 1, "extract_dir"),
    ]

    patches: List[Any] = []
    for module, function_name, arg_index, kwarg_key in functions_to_wrap:
        # Skip anything that doesn't exist in this version of Python.
        if not hasattr(module, function_name):
            continue

        # These values must be passed to a separate function
        # to ensure the variable closures work correctly.
        function_path = f"{module.__name__}.{function_name}"
        function = getattr(module, function_name)
        wrapped = wrap_function(function, function_path, arg_index, kwarg_key, current_root)
        patches.append(mock.patch(function_path, wrapped))

    # open() must be separately wrapped.
    function_path = "builtins.open"
    wrapped = wrap_open(current_root)
    patches.append(mock.patch(function_path, wrapped))

    # Block all access to bad functions.
    if hasattr(os, "chroot"):
        patches.append(mock.patch("os.chroot", return_value=None))

    # Patch tempfile._mkstemp_inner() so tempfile.TemporaryFile()
    # can unlink files immediately.
    mkstemp_inner = tempfile._mkstemp_inner  # type: ignore # noqa: SLF001

    def wrap_mkstemp_inner(*args: Any, **kwargs: Any) -> Any:
        (fd, name) = mkstemp_inner(*args, **kwargs)
        allowed_tempfile_internal_unlink_calls.append(name)
        return fd, name

    patches.append(mock.patch("tempfile._mkstemp_inner", wrap_mkstemp_inner))

    [patch.start() for patch in patches]
    try:
        yield current_root
    finally:
        # Patches must be stopped in reverse order because some patches are nested.
        # Stopping in the reverse order restores the original function.
        for patch in reversed(patches):
            patch.stop()
        os.chdir(current_working_directory)
        if sys.version_info >= (3, 12):
            rmtree(current_root, onexc=rmtree_error_handler)
        else:
            rmtree(current_root, onerror=rmtree_error_handler)


@pytest.fixture
def home(monkeypatch: pytest.MonkeyPatch, root: str) -> str:
    """Create a home directory for the duration of the test.

    On *nix, the environment variable "HOME" will be mocked.
    On Windows, the environment variable "USERPROFILE" will be mocked.
    """

    home = os.path.abspath(os.path.join(root, "home/user"))
    os.makedirs(home)
    if sys.platform == "win32":
        monkeypatch.setenv("USERPROFILE", home)
    else:
        monkeypatch.setenv("HOME", home)
    return home


class Dotfiles:
    """Create and manage a dotfiles directory for a test."""

    def __init__(self, root: str):
        self.root = root
        self.config = None
        self._config_filename: Optional[str] = None
        self.directory = os.path.join(root, "dotfiles")
        os.mkdir(self.directory)

    def makedirs(self, path: str) -> None:
        os.makedirs(os.path.abspath(os.path.join(self.directory, path)))

    def write(self, path: str, content: str = "") -> None:
        path = os.path.abspath(os.path.join(self.directory, path))
        if not os.path.exists(os.path.dirname(path)):
            os.makedirs(os.path.dirname(path))
        with open(path, "w") as file:
            file.write(content)

    def write_config(self, config: Any, serializer: str = "yaml", path: Optional[str] = None) -> str:
        """Write a dotbot config and return the filename."""

        assert serializer in {"json", "yaml"}, "Only json and yaml are supported"
        if serializer == "yaml":
            serialize: Callable[[Any], str] = yaml.dump
        else:  # serializer == "json"
            serialize = json.dumps

        if path is not None:
            msg = "The config file path must be an absolute path"
            assert path == os.path.abspath(path), msg

            msg = f"The config file path must be rooted in {root}"
            assert path[: len(str(root))] == str(root), msg

            self._config_filename = path
        else:
            self._config_filename = os.path.join(self.directory, "install.conf.yaml")
        self.config = config

        with open(self._config_filename, "w") as file:
            file.write(serialize(config))
        return self._config_filename

    @property
    def config_filename(self) -> str:
        assert self._config_filename is not None
        return self._config_filename


@pytest.fixture
def dotfiles(root: str) -> Dotfiles:
    """Create a dotfiles directory."""

    return Dotfiles(root)


@pytest.fixture
def run_dotbot(dotfiles: Dotfiles) -> Callable[..., None]:
    """Run dotbot.

    When calling `runner()`, only CLI arguments need to be specified.

    If the keyword-only argument *custom* is True
    then the CLI arguments will not be modified,
    and the caller will be responsible for all CLI arguments.
    """

    def runner(*argv: Any, **kwargs: Any) -> None:
        argv = ("dotbot", *argv)
        if kwargs.get("custom", False) is not True:
            argv = (*argv, "-c", dotfiles.config_filename)
        with mock.patch("sys.argv", list(argv)):
            dotbot.cli.main()

    return runner


================================================
FILE: tests/dotbot_plugin_context_plugin.py
================================================
# https://github.com/anishathalye/dotbot/issues/339
# plugins should be able to instantiate a Dispatcher with all the plugins

from typing import Any

import dotbot
from dotbot.dispatcher import Dispatcher


class Dispatch(dotbot.Plugin):
    def can_handle(self, directive: str) -> bool:
        return directive == "dispatch"

    def handle(self, directive: str, data: Any) -> bool:
        if directive != "dispatch":
            msg = f"Dispatch cannot handle directive {directive}"
            raise ValueError(msg)
        dispatcher = Dispatcher(
            base_directory=self._context.base_directory(),
            options=self._context.options(),
            plugins=self._context.plugins(),
        )
        return dispatcher.dispatch(data)


================================================
FILE: tests/dotbot_plugin_counter.py
================================================
"""Test plugin that counts how many times it's executed.

This file is used to test that duplicate plugin references
don't cause the plugin to be loaded/executed multiple times.
"""

import os.path
from typing import Any

import dotbot


class Counter(dotbot.Plugin):
    def can_handle(self, directive: str) -> bool:
        return directive == "counter"

    def handle(self, directive: str, _data: Any) -> bool:
        if directive != "counter":
            msg = f"Counter cannot handle directive {directive}"
            raise ValueError(msg)

        counter_file = os.path.abspath(os.path.expanduser("~/counter"))
        if os.path.exists(counter_file):
            with open(counter_file) as f:
                count = int(f.read().strip())
        else:
            count = 0
        count += 1
        with open(counter_file, "w") as f:
            f.write(str(count))
        return True


================================================
FILE: tests/dotbot_plugin_directory.py
================================================
"""Test that a plugin can be loaded by directory.

This file is copied to a location with the name "directory.py",
and is then loaded from within the `test_cli.py` code.
"""

import os.path
from typing import Any

import dotbot


class Directory(dotbot.Plugin):
    def can_handle(self, directive: str) -> bool:
        return directive == "plugin_directory"

    def handle(self, directive: str, data: Any) -> bool:
        if directive != "plugin_directory":
            msg = f"Directory cannot handle directive {directive}"
            raise ValueError(msg)

        if data != "no-check-context":
            self._log.debug("Attempting to get options from Context")
            options = self._context.options()
            if len(options.plugin_dirs) != 1:
                self._log.debug(f"Context.options.plugin_dirs length is {len(options.plugin_dirs)}, expected 1")
                return False

        with open(os.path.abspath(os.path.expanduser("~/flag-directory")), "w") as file:
            file.write("directory plugin loading works")
        return True


================================================
FILE: tests/dotbot_plugin_dispatcher_no_plugins.py
================================================
# https://github.com/anishathalye/dotbot/issues/339, https://github.com/anishathalye/dotbot/pull/332
# if plugins instantiate a Dispatcher without explicitly passing in plugins,
# the Dispatcher should have access to all plugins (matching context.plugins())

from typing import Any

import dotbot
from dotbot.dispatcher import Dispatcher


class Dispatch(dotbot.Plugin):
    def can_handle(self, directive: str) -> bool:
        return directive == "dispatch"

    def handle(self, directive: str, data: Any) -> bool:
        if directive != "dispatch":
            msg = f"Dispatch cannot handle directive {directive}"
            raise ValueError(msg)
        dispatcher = Dispatcher(
            base_directory=self._context.base_directory(),
            options=self._context.options(),
        )
        return dispatcher.dispatch(data)


================================================
FILE: tests/dotbot_plugin_dry_run.py
================================================
import os
from typing import Any

import dotbot


class DryRun(dotbot.Plugin):
    """A plugin that is aware of dry-run mode."""

    _directive = "dry_run"
    supports_dry_run = True

    def can_handle(self, directive: str) -> bool:
        return directive == self._directive

    def handle(self, _directive: str, _data: Any) -> bool:
        if self._context.dry_run():
            self._log.action("Would execute dry run")
        else:
            with open(os.path.abspath(os.path.expanduser("~/flag-dry-run")), "w") as file:
                file.write("Dry run executed")
        return True


================================================
FILE: tests/dotbot_plugin_file.py
================================================
"""Test that a plugin can be loaded by filename.

This file is copied to a location with the name "file.py",
and is then loaded from within the `test_cli.py` code.
"""

import os.path
from typing import Any

import dotbot


class File(dotbot.Plugin):
    def can_handle(self, directive: str) -> bool:
        return directive == "plugin_file"

    def handle(self, directive: str, data: Any) -> bool:
        if directive != "plugin_file":
            msg = f"File cannot handle directive {directive}"
            raise ValueError(msg)

        if data != "no-check-context":
            self._log.debug("Attempting to get options from Context")
            options = self._context.options()
            if len(options.plugins) != 1:
                self._log.debug(f"Context.options.plugins length is {len(options.plugins)}, expected 1")
                return False
            if not options.plugins[0].endswith("file.py"):
                self._log.debug(f"Context.options.plugins[0] is {options.plugins[0]}, expected end with file.py")
                return False

        with open(os.path.abspath(os.path.expanduser("~/flag-file")), "w") as file:
            file.write("file plugin loading works")
        return True


================================================
FILE: tests/dotbot_plugin_issue_357.py
================================================
from typing import Any

from dotbot.plugin import Plugin
from dotbot.plugins import Clean, Create, Link, Shell

# https://github.com/anishathalye/dotbot/issues/357
# if we import from dotbot.plugins, the built-in plugins get executed multiple times
_: Any = Clean
_ = Create
_ = Link
_ = Shell


class NoopPlugin(Plugin):
    _directive = "noop"

    def can_handle(self, directive: str) -> bool:
        return directive == self._directive

    def handle(self, directive: str, _data: Any) -> bool:
        if directive != self._directive:
            msg = f"NoopPlugin cannot handle directive {directive}"
            raise ValueError(msg)
        return True


================================================
FILE: tests/test_bin_dotbot.py
================================================
import os
import shutil
import subprocess
from typing import Optional

import pytest

from tests.conftest import Dotfiles


@pytest.mark.skipif(
    "sys.platform == 'win32'",
    reason="The hybrid sh/Python dotbot script doesn't run on Windows platforms",
)
@pytest.mark.parametrize("python_name", [None, "python", "python3"])
def test_find_python_executable(python_name: Optional[str], home: str, dotfiles: Dotfiles) -> None:
    """Verify that the sh/Python hybrid dotbot executable can find Python."""

    dotfiles.write_config([])
    dotbot_executable = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "bin", "dotbot")

    # Create a link to sh.
    tmp_bin = os.path.join(home, "tmp_bin")
    os.makedirs(tmp_bin)
    sh_path = shutil.which("sh")
    assert sh_path is not None
    os.symlink(sh_path, os.path.join(tmp_bin, "sh"))

    if python_name is not None:
        with open(os.path.join(tmp_bin, python_name), "w") as file:
            file.write("#!" + tmp_bin + "/sh\n")
            file.write("exit 0\n")
        os.chmod(os.path.join(tmp_bin, python_name), 0o777)
    env = dict(os.environ)
    env["PATH"] = tmp_bin

    if python_name is not None:
        subprocess.check_call(
            [dotbot_executable, "-c", dotfiles.config_filename],
            env=env,
        )
    else:
        with pytest.raises(subprocess.CalledProcessError):
            subprocess.check_call(
                [dotbot_executable, "-c", dotfiles.config_filename],
                env=env,
            )


================================================
FILE: tests/test_clean.py
================================================
import os
import sys
from typing import Callable

import pytest

from tests.conftest import Dotfiles


def test_clean_default(root: str, home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
    """Verify clean uses default unless overridden."""

    os.symlink(os.path.join(root, "nowhere"), os.path.join(home, ".g"))
    dotfiles.write_config(
        [
            {
                "clean": {
                    "~/nonexistent": {"force": True},
                    "~/": None,
                },
            }
        ]
    )
    run_dotbot()

    assert not os.path.isdir(os.path.join(home, "nonexistent"))
    assert os.path.islink(os.path.join(home, ".g"))


def test_clean_environment_variable_expansion(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
    """Verify clean expands environment variables."""

    os.symlink(os.path.join(dotfiles.directory, "f"), os.path.join(home, ".f"))
    variable = "$HOME"
    if sys.platform == "win32":
        variable = "$USERPROFILE"
    dotfiles.write_config([{"clean": [variable]}])
    run_dotbot()

    assert not os.path.islink(os.path.join(home, ".f"))


def test_clean_missing(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
    """Verify clean deletes links to missing files."""

    dotfiles.write("f")
    os.symlink(os.path.join(dotfiles.directory, "f"), os.path.join(home, ".f"))
    os.symlink(os.path.join(dotfiles.directory, "g"), os.path.join(home, ".g"))
    dotfiles.write_config([{"clean": ["~"]}])
    run_dotbot()

    assert os.path.islink(os.path.join(home, ".f"))
    assert not os.path.islink(os.path.join(home, ".g"))


def test_clean_nonexistent(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
    """Verify clean ignores nonexistent directories."""

    dotfiles.write_config([{"clean": ["~", "~/fake"]}])
    run_dotbot()  # Nonexistent directories should not raise exceptions.

    assert not os.path.isdir(os.path.join(home, "fake"))


def test_clean_outside_force(root: str, home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
    """Verify clean forced to remove files linking outside dotfiles directory."""

    os.symlink(os.path.join(root, "nowhere"), os.path.join(home, ".g"))
    dotfiles.write_config([{"clean": {"~/": {"force": True}}}])
    run_dotbot()

    assert not os.path.islink(os.path.join(home, ".g"))


def test_clean_outside(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
    """Verify clean ignores files linking outside dotfiles directory."""

    os.symlink(os.path.join(dotfiles.directory, "f"), os.path.join(home, ".f"))
    os.symlink(os.path.join(home, "g"), os.path.join(home, ".g"))
    dotfiles.write_config([{"clean": ["~"]}])
    run_dotbot()

    assert not os.path.islink(os.path.join(home, ".f"))
    assert os.path.islink(os.path.join(home, ".g"))


def test_clean_recursive_1(root: str, home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
    """Verify clean respects when the recursive directive is off (default)."""

    os.makedirs(os.path.join(home, "a", "b"))
    os.symlink(os.path.join(root, "nowhere"), os.path.join(home, "c"))
    os.symlink(os.path.join(root, "nowhere"), os.path.join(home, "a", "d"))
    os.symlink(os.path.join(root, "nowhere"), os.path.join(home, "a", "b", "e"))
    dotfiles.write_config([{"clean": {"~": {"force": True}}}])
    run_dotbot()

    assert not os.path.islink(os.path.join(home, "c"))
    assert os.path.islink(os.path.join(home, "a", "d"))
    assert os.path.islink(os.path.join(home, "a", "b", "e"))


def test_clean_recursive_2(root: str, home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
    """Verify clean respects when the recursive directive is on."""

    os.makedirs(os.path.join(home, "a", "b"))
    os.symlink(os.path.join(root, "nowhere"), os.path.join(home, "c"))
    os.symlink(os.path.join(root, "nowhere"), os.path.join(home, "a", "d"))
    os.symlink(os.path.join(root, "nowhere"), os.path.join(home, "a", "b", "e"))
    dotfiles.write_config([{"clean": {"~": {"force": True, "recursive": True}}}])
    run_dotbot()

    assert not os.path.islink(os.path.join(home, "c"))
    assert not os.path.islink(os.path.join(home, "a", "d"))
    assert not os.path.islink(os.path.join(home, "a", "b", "e"))


def test_clean_defaults_1(root: str, home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
    """Verify that clean doesn't erase non-dotfiles links by default."""

    os.symlink(os.path.join(root, "nowhere"), os.path.join(home, ".g"))
    dotfiles.write_config([{"clean": ["~"]}])
    run_dotbot()

    assert os.path.islink(os.path.join(home, ".g"))


def test_clean_defaults_2(root: str, home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
    """Verify that explicit clean defaults override the implicit default."""

    os.symlink(os.path.join(root, "nowhere"), os.path.join(home, ".g"))
    dotfiles.write_config(
        [
            {"defaults": {"clean": {"force": True}}},
            {"clean": ["~"]},
        ]
    )
    run_dotbot()

    assert not os.path.islink(os.path.join(home, ".g"))


def test_clean_dry_run(
    capfd: pytest.CaptureFixture[str], root: str, home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]
) -> None:
    """Verify that the clean plugin does not delete files during a dry run."""

    os.symlink(os.path.join(root, "nowhere"), os.path.join(home, ".g"))
    dotfiles.write_config([{"clean": {"~/": {"force": True}}}])
    run_dotbot("-n")

    assert os.path.islink(os.path.join(home, ".g"))

    lines = capfd.readouterr().out.splitlines()
    assert any(
        f"Would remove invalid link {os.path.join(home, '.g')} -> {os.path.join(root, 'nowhere')}" in line
        for line in lines
    )


def test_clean_dry_run_recursive(
    capfd: pytest.CaptureFixture[str], root: str, home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]
) -> None:
    """Verify that the clean plugin does not delete files during a recursive dry run."""

    os.makedirs(os.path.join(home, "a", "b"))
    os.symlink(os.path.join(root, "nowhere"), os.path.join(home, "c"))
    os.symlink(os.path.join(root, "nowhere"), os.path.join(home, "a", "d"))
    os.symlink(os.path.join(root, "nowhere"), os.path.join(home, "a", "b", "e"))
    dotfiles.write_config([{"clean": {"~": {"force": True, "recursive": True}}}])
    run_dotbot("-n")

    assert os.path.islink(os.path.join(home, "c"))
    assert os.path.islink(os.path.join(home, "a", "d"))
    assert os.path.islink(os.path.join(home, "a", "b", "e"))

    lines = capfd.readouterr().out.splitlines()
    assert any(
        f"Would remove invalid link {os.path.join(home, 'c')} -> {os.path.join(root, 'nowhere')}" in line
        for line in lines
    )
    assert any(
        f"Would remove invalid link {os.path.join(home, 'a', 'd')} -> {os.path.join(root, 'nowhere')}" in line
        for line in lines
    )
    assert any(
        f"Would remove invalid link {os.path.join(home, 'a', 'b', 'e')} -> {os.path.join(root, 'nowhere')}" in line
        for line in lines
    )


================================================
FILE: tests/test_cli.py
================================================
import os
import shutil
from typing import Callable

import pytest

from tests.conftest import Dotfiles


def test_except_create(
    capfd: pytest.CaptureFixture[str], home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]
) -> None:
    """Verify that `--except` works as intended."""

    dotfiles.write_config(
        [
            {"create": ["~/a"]},
            {
                "shell": [
                    {"command": "echo success", "stdout": True},
                ]
            },
        ]
    )
    run_dotbot("--except", "create")

    assert not os.path.exists(os.path.join(home, "a"))
    stdout = capfd.readouterr().out.splitlines()
    assert any(line.startswith("success") for line in stdout)


def test_except_shell(
    capfd: pytest.CaptureFixture[str], home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]
) -> None:
    """Verify that `--except` works as intended."""

    dotfiles.write_config(
        [
            {"create": ["~/a"]},
            {
                "shell": [
                    {"command": "echo failure", "stdout": True},
                ]
            },
        ]
    )
    run_dotbot("--except", "shell")

    assert os.path.exists(os.path.join(home, "a"))
    stdout = capfd.readouterr().out.splitlines()
    assert not any(line.startswith("failure") for line in stdout)


def test_except_multiples(
    capfd: pytest.CaptureFixture[str], home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]
) -> None:
    """Verify that `--except` works with multiple exceptions."""

    dotfiles.write_config(
        [
            {"create": ["~/a"]},
            {
                "shell": [
                    {"command": "echo failure", "stdout": True},
                ]
            },
        ]
    )
    run_dotbot("--except", "create", "shell")

    assert not os.path.exists(os.path.join(home, "a"))
    stdout = capfd.readouterr().out.splitlines()
    assert not any(line.startswith("failure") for line in stdout)


def test_exit_on_failure(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
    """Verify that processing can halt immediately on failures."""

    dotfiles.write_config(
        [
            {"create": ["~/a"]},
            {"shell": ["this_is_not_a_command"]},
            {"create": ["~/b"]},
        ]
    )
    with pytest.raises(SystemExit):
        run_dotbot("-x")

    assert os.path.isdir(os.path.join(home, "a"))
    assert not os.path.isdir(os.path.join(home, "b"))


def test_only(
    capfd: pytest.CaptureFixture[str], home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]
) -> None:
    """Verify that `--only` works as intended."""

    dotfiles.write_config(
        [
            {"create": ["~/a"]},
            {"shell": [{"command": "echo success", "stdout": True}]},
        ]
    )
    run_dotbot("--only", "shell")

    assert not os.path.exists(os.path.join(home, "a"))
    stdout = capfd.readouterr().out.splitlines()
    assert any(line.startswith("success") for line in stdout)


def test_only_with_defaults(
    capfd: pytest.CaptureFixture[str], home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]
) -> None:
    """Verify that `--only` does not suppress defaults."""

    dotfiles.write_config(
        [
            {"defaults": {"shell": {"stdout": True}}},
            {"create": ["~/a"]},
            {"shell": [{"command": "echo success"}]},
        ]
    )
    run_dotbot("--only", "shell")

    assert not os.path.exists(os.path.join(home, "a"))
    stdout = capfd.readouterr().out.splitlines()
    assert any(line.startswith("success") for line in stdout)


def test_only_with_multiples(
    capfd: pytest.CaptureFixture[str], home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]
) -> None:
    """Verify that `--only` works as intended."""

    dotfiles.write_config(
        [
            {"create": ["~/a"]},
            {"shell": [{"command": "echo success", "stdout": True}]},
            {"link": ["~/.f"]},
        ]
    )
    run_dotbot("--only", "create", "shell")

    assert os.path.isdir(os.path.join(home, "a"))
    stdout = capfd.readouterr().out.splitlines()
    assert any(line.startswith("success") for line in stdout)
    assert not os.path.exists(os.path.join(home, ".f"))


def test_plugin_loading_file(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
    """Verify that plugins can be loaded by file."""

    plugin_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), "dotbot_plugin_file.py")
    shutil.copy(plugin_file, os.path.join(dotfiles.directory, "file.py"))
    dotfiles.write_config([{"plugin_file": "~"}])
    run_dotbot("--plugin", os.path.join(dotfiles.directory, "file.py"))

    with open(os.path.join(home, "flag-file")) as file:
        assert file.read() == "file plugin loading works"


def test_plugin_loading_directory(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
    """Verify that plugins can be loaded from a directory."""

    dotfiles.makedirs("plugins")
    plugin_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), "dotbot_plugin_directory.py")
    shutil.copy(plugin_file, os.path.join(dotfiles.directory, "plugins", "directory.py"))
    dotfiles.write_config([{"plugin_directory": "~"}])
    run_dotbot("--plugin-dir", os.path.join(dotfiles.directory, "plugins"))

    with open(os.path.join(home, "flag-directory")) as file:
        assert file.read() == "directory plugin loading works"


def test_issue_357(
    capfd: pytest.CaptureFixture[str], home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]
) -> None:
    """Verify that built-in plugins are only executed once, when
    using a plugin that imports from dotbot.plugins."""

    _ = home
    plugin_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), "dotbot_plugin_issue_357.py")
    dotfiles.write_config([{"shell": [{"command": "echo apple", "stdout": True}]}])

    run_dotbot("--plugin", plugin_file)

    assert len([line for line in capfd.readouterr().out.splitlines() if line.strip() == "apple"]) == 1


def test_disable_builtin_plugins(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
    """Verify that builtin plugins can be disabled."""

    dotfiles.write("f", "apple")
    dotfiles.write_config([{"link": {"~/.f": "f"}}])

    # The link directive will be unhandled so dotbot will raise SystemExit.
    with pytest.raises(SystemExit):
        run_dotbot("--disable-built-in-plugins")

    assert not os.path.exists(os.path.join(home, ".f"))


def test_plugin_context_plugin(
    capfd: pytest.CaptureFixture[str], home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]
) -> None:
    """Verify that the plugin context is available to plugins."""

    _ = home
    plugin_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), "dotbot_plugin_context_plugin.py")
    shutil.copy(plugin_file, os.path.join(dotfiles.directory, "plugin.py"))
    dotfiles.write_config([{"dispatch": [{"shell": [{"command": "echo apple", "stdout": True}]}]}])
    run_dotbot("--plugin", os.path.join(dotfiles.directory, "plugin.py"))

    stdout = capfd.readouterr().out.splitlines()
    assert any(line.startswith("apple") for line in stdout)


def test_plugin_dispatcher_no_plugins(
    capfd: pytest.CaptureFixture[str], home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]
) -> None:
    """Verify that plugins instantiating Dispatcher without plugins work."""

    _ = home
    plugin_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), "dotbot_plugin_dispatcher_no_plugins.py")
    shutil.copy(plugin_file, os.path.join(dotfiles.directory, "plugin.py"))
    dotfiles.write_config([{"dispatch": [{"shell": [{"command": "echo apple", "stdout": True}]}]}])
    run_dotbot("--plugin", os.path.join(dotfiles.directory, "plugin.py"))

    stdout = capfd.readouterr().out.splitlines()
    assert any(line.startswith("apple") for line in stdout)


def test_dry_run_unaware_plugin(
    capfd: pytest.CaptureFixture[str], home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]
) -> None:
    """Verify that plugins not aware of dry-run mode do not execute actions during a dry run."""

    plugin_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), "dotbot_plugin_file.py")
    shutil.copy(plugin_file, os.path.join(dotfiles.directory, "file.py"))
    dotfiles.write_config([{"plugin_file": "~"}])
    run_dotbot("--plugin", os.path.join(dotfiles.directory, "file.py"), "--dry-run")

    assert not os.path.exists(os.path.join(home, "flag-file"))

    stdout = capfd.readouterr().out.splitlines()
    assert any(line.strip() == "Skipping dry-run-unaware plugin File" for line in stdout)


def test_dry_run_aware_plugin(
    capfd: pytest.CaptureFixture[str], home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]
) -> None:
    """Verify that plugins that are aware of dry-run mode do execute during a dry run."""

    plugin_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), "dotbot_plugin_dry_run.py")
    shutil.copy(plugin_file, os.path.join(dotfiles.directory, "dry_run.py"))
    dotfiles.write_config([{"dry_run": "~"}])
    run_dotbot("--plugin", os.path.join(dotfiles.directory, "dry_run.py"), "--dry-run")

    assert not os.path.exists(os.path.join(home, "flag-dry-run"))
    stdout = capfd.readouterr().out.splitlines()
    assert any(line.startswith("Would execute dry run") for line in stdout)


def test_dry_run_aware_plugin_no_dry_run(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
    """Verify that plugins that are aware of dry-run mode do execute without dry run."""

    plugin_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), "dotbot_plugin_dry_run.py")
    shutil.copy(plugin_file, os.path.join(dotfiles.directory, "dry_run.py"))
    dotfiles.write_config([{"dry_run": "~"}])
    run_dotbot("--plugin", os.path.join(dotfiles.directory, "dry_run.py"))
    with open(os.path.join(home, "flag-dry-run")) as file:
        assert file.read() == "Dry run executed"


================================================
FILE: tests/test_config.py
================================================
import json
import os
from typing import Callable

from tests.conftest import Dotfiles


def test_config_blank(dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
    """Verify blank configs work."""

    dotfiles.write_config([])
    run_dotbot()


def test_config_empty(dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
    """Verify empty configs work."""

    dotfiles.write("config.yaml", "")
    run_dotbot("-c", os.path.join(dotfiles.directory, "config.yaml"), custom=True)


def test_json(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
    """Verify JSON configs work."""

    document = json.dumps([{"create": ["~/d"]}])
    dotfiles.write("config.json", document)
    run_dotbot("-c", os.path.join(dotfiles.directory, "config.json"), custom=True)

    assert os.path.isdir(os.path.join(home, "d"))


def test_json_tabs(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
    """Verify JSON configs with tabs work."""

    document = """[\n\t{\n\t\t"create": ["~/d"]\n\t}\n]"""
    dotfiles.write("config.json", document)
    run_dotbot("-c", os.path.join(dotfiles.directory, "config.json"), custom=True)

    assert os.path.isdir(os.path.join(home, "d"))


def test_multiple_config(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
    """Verify that passing multiple configs works."""

    dotfiles.write("config1.json", json.dumps([{"create": ["~/d1"]}]))
    dotfiles.write("config2.json", json.dumps([{"create": ["~/d2"]}]))

    run_dotbot(
        "-c",
        os.path.join(dotfiles.directory, "config1.json"),
        os.path.join(dotfiles.directory, "config2.json"),
        custom=True,
    )

    assert os.path.isdir(os.path.join(home, "d1"))
    assert os.path.isdir(os.path.join(home, "d2"))


================================================
FILE: tests/test_create.py
================================================
import os
import stat
from typing import Callable

import pytest

from tests.conftest import Dotfiles


@pytest.mark.parametrize("directory", ["~/a", "~/b/c"])
def test_directory_creation(home: str, directory: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
    """Test creating directories, including nested directories."""

    _ = home
    dotfiles.write_config([{"create": [directory]}])
    run_dotbot()

    expanded_directory = os.path.abspath(os.path.expanduser(directory))
    assert os.path.isdir(expanded_directory)
    assert os.stat(expanded_directory).st_mode & 0o777 == 0o777


def test_default_mode(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
    """Test creating a directory with an explicit default mode.

    Note: `os.chmod()` on Windows only supports changing write permissions.
    Therefore, this test is restricted to testing read-only access.
    """

    _ = home
    read_only = 0o777 - stat.S_IWUSR - stat.S_IWGRP - stat.S_IWOTH
    config = [{"defaults": {"create": {"mode": read_only}}}, {"create": ["~/a"]}]
    dotfiles.write_config(config)
    run_dotbot()

    directory = os.path.abspath(os.path.expanduser("~/a"))
    assert os.stat(directory).st_mode & stat.S_IWUSR == 0
    assert os.stat(directory).st_mode & stat.S_IWGRP == 0
    assert os.stat(directory).st_mode & stat.S_IWOTH == 0


def test_default_mode_override(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
    """Test creating a directory that overrides an explicit default mode.

    Note: `os.chmod()` on Windows only supports changing write permissions.
    Therefore, this test is restricted to testing read-only access.
    """

    _ = home
    read_only = 0o777 - stat.S_IWUSR - stat.S_IWGRP - stat.S_IWOTH
    config = [
        {"defaults": {"create": {"mode": read_only}}},
        {"create": {"~/a": {"mode": 0o777}}},
    ]
    dotfiles.write_config(config)
    run_dotbot()

    directory = os.path.abspath(os.path.expanduser("~/a"))
    assert os.stat(directory).st_mode & stat.S_IWUSR == stat.S_IWUSR
    assert os.stat(directory).st_mode & stat.S_IWGRP == stat.S_IWGRP
    assert os.stat(directory).st_mode & stat.S_IWOTH == stat.S_IWOTH


def test_create_dry_run(
    capfd: pytest.CaptureFixture[str], home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]
) -> None:
    """Verify that the create plugin does not create directories during a dry run."""

    os.makedirs(os.path.join(home, "existing"))
    dotfiles.write_config([{"create": ["~/a", "~/existing"]}])
    run_dotbot("-n", "-v")

    directory = os.path.abspath(os.path.expanduser("~/a"))
    assert not os.path.exists(directory)

    lines = capfd.readouterr().out.splitlines()
    assert any(line.strip() == f"Would create path {os.path.join(home, 'a')}" for line in lines)
    assert any(f"Path exists {os.path.join(home, 'existing')}" == line.strip() for line in lines)


================================================
FILE: tests/test_link.py
================================================
import os
import pathlib
import stat
import sys
from datetime import datetime, timedelta, timezone
from typing import Any, Callable, Dict, List, Optional
from unittest.mock import patch

import pytest

from dotbot.plugins.link import Link
from tests.conftest import Dotfiles


def test_link_canonicalization(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
    """Verify links to symlinked targets are canonical.

    "Canonical", here, means that dotbot does not create symlinks
    that point to intermediary symlinks.
    """

    dotfiles.write("f", "apple")
    dotfiles.write_config([{"link": {"~/.f": {"path": "f"}}}])

    # Point to the config file in a symlinked dotfiles directory.
    dotfiles_symlink = os.path.join(home, "dotfiles-symlink")
    os.symlink(dotfiles.directory, dotfiles_symlink)
    config_file = os.path.join(dotfiles_symlink, os.path.basename(dotfiles.config_filename))
    run_dotbot("-c", config_file, custom=True)

    expected = os.path.join(dotfiles.directory, "f")
    actual = os.readlink(os.path.abspath(os.path.expanduser("~/.f")))
    if sys.platform == "win32" and actual.startswith("\\\\?\\"):
        actual = actual[4:]
    assert expected == actual


@pytest.mark.parametrize("dst", ["~/.f", "~/f"])
@pytest.mark.parametrize("include_force", [True, False])
def test_link_default_target(
    dst: str,
    include_force: bool,  # noqa: FBT001
    home: str,
    dotfiles: Dotfiles,
    run_dotbot: Callable[..., None],
) -> None:
    """Verify that default targets are calculated correctly.

    This test includes verifying files with and without leading periods,
    as well as verifying handling of None dict values.
    """

    _ = home
    dotfiles.write("f", "apple")
    config = [
        {
            "link": {
                dst: {"force": False} if include_force else None,
            }
        }
    ]
    dotfiles.write_config(config)
    run_dotbot()

    with open(os.path.abspath(os.path.expanduser(dst))) as file:
        assert file.read() == "apple"


def test_link_environment_user_expansion_link_name(
    home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]
) -> None:
    """Verify link expands user in link name."""

    _ = home
    target = "~/f"
    link_name = "~/g"
    with open(os.path.abspath(os.path.expanduser(target)), "w") as file:
        file.write("apple")
    dotfiles.write_config([{"link": {link_name: target}}])
    run_dotbot()

    with open(os.path.abspath(os.path.expanduser(link_name))) as file:
        assert file.read() == "apple"


def test_link_environment_variable_expansion_target(
    monkeypatch: pytest.MonkeyPatch, home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]
) -> None:
    """Verify link expands environment variables in target."""

    _ = home
    monkeypatch.setenv("APPLE", "h")
    link_name = "~/.i"
    target = "$APPLE"
    dotfiles.write("h", "grape")
    dotfiles.write_config([{"link": {link_name: target}}])
    run_dotbot()

    with open(os.path.abspath(os.path.expanduser(link_name))) as file:
        assert file.read() == "grape"


def test_link_environment_variable_expansion_target_extended(
    monkeypatch: pytest.MonkeyPatch, home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]
) -> None:
    """Verify link expands environment variables in extended config syntax."""

    _ = home
    monkeypatch.setenv("APPLE", "h")
    link_name = "~/.i"
    target = "$APPLE"
    dotfiles.write("h", "grape")
    dotfiles.write_config([{"link": {link_name: {"path": target, "relink": True}}}])
    run_dotbot()

    with open(os.path.abspath(os.path.expanduser(link_name))) as file:
        assert file.read() == "grape"


def test_link_environment_variable_expansion_link_name(
    monkeypatch: pytest.MonkeyPatch, home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]
) -> None:
    """Verify link expands environment variables in link name.

    If the variable doesn't exist, the "variable" must not be replaced.
    """

    monkeypatch.setenv("ORANGE", ".config")
    monkeypatch.setenv("BANANA", "g")
    monkeypatch.delenv("PEAR", raising=False)

    dotfiles.write("f", "apple")
    dotfiles.write("h", "grape")

    config = [
        {
            "link": {
                "~/${ORANGE}/$BANANA": {
                    "path": "f",
                    "create": True,
                },
                "~/$PEAR": "h",
            }
        }
    ]
    dotfiles.write_config(config)
    run_dotbot()

    with open(os.path.join(home, ".config", "g")) as file:
        assert file.read() == "apple"
    with open(os.path.join(home, "$PEAR")) as file:
        assert file.read() == "grape"


def test_link_environment_variable_unset(
    monkeypatch: pytest.MonkeyPatch, home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]
) -> None:
    """Verify link leaves unset environment variables."""

    monkeypatch.delenv("ORANGE", raising=False)
    dotfiles.write("$ORANGE", "apple")
    dotfiles.write_config([{"link": {"~/f": "$ORANGE"}}])
    run_dotbot()

    with open(os.path.join(home, "f")) as file:
        assert file.read() == "apple"


def test_link_force_leaves_when_nonexistent(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
    """Verify force doesn't erase existing files when targets are nonexistent."""

    os.mkdir(os.path.join(home, "dir"))
    open(os.path.join(home, "file"), "w").close()
    config = [
        {
            "link": {
                "~/dir": {"path": "dir", "force": True},
                "~/file": {"path": "file", "force": True},
            }
        }
    ]
    dotfiles.write_config(config)
    with pytest.raises(SystemExit):
        run_dotbot()

    assert os.path.isdir(os.path.join(home, "dir"))
    assert os.path.isfile(os.path.join(home, "file"))


def test_link_force_overwrite_symlink(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
    """Verify force overwrites a symlinked directory."""

    os.mkdir(os.path.join(home, "dir"))
    dotfiles.write("dir/f")
    os.symlink(home, os.path.join(home, ".dir"))

    config = [{"link": {"~/.dir": {"path": "dir", "force": True}}}]
    dotfiles.write_config(config)
    run_dotbot()

    assert os.path.isfile(os.path.join(home, ".dir", "f"))


def test_link_force_directory(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
    """Verify force deletes directories."""

    os.mkdir(os.path.join(home, ".dir"))
    with open(os.path.join(home, ".dir", "f"), "w") as f:
        f.write("apple")
    dotfiles.write("dir/f", "banana")

    config = [{"link": {"~/.dir": {"path": "dir", "force": True}}}]
    dotfiles.write_config(config)
    run_dotbot()

    with open(os.path.join(home, ".dir", "f")) as file:
        assert file.read() == "banana"


def test_link_backup_directory(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
    """Verify that a backup directory is created if destination directory exists."""

    os.mkdir(os.path.join(home, ".dir"))
    with open(os.path.join(home, ".dir", "f"), "w") as f:
        f.write("apple")
    dotfiles.write("dir/f", "banana")

    config = [{"link": {"~/.dir": {"path": "dir", "backup": True}}}]
    dotfiles.write_config(config)
    run_dotbot()

    backup_dirs = [d for d in os.listdir(home) if d.startswith(".dir.dotbot-backup")]
    assert len(backup_dirs) == 1
    with open(os.path.join(home, backup_dirs[0], "f")) as file:
        assert file.read() == "apple"
    with open(os.path.join(home, ".dir", "f")) as file:
        assert file.read() == "banana"


def test_link_backup_file(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
    """Verify that a backup file is created if destination file exists."""

    with open(os.path.join(home, ".file"), "w") as f:
        f.write("apple")
    dotfiles.write("file", "banana")

    config = [{"link": {"~/.file": {"path": "file", "backup": True}}}]
    dotfiles.write_config(config)
    run_dotbot()

    backup_files = [f for f in os.listdir(home) if f.startswith(".file.dotbot-backup")]
    assert len(backup_files) == 1
    with open(os.path.join(home, backup_files[0])) as file:
        assert file.read() == "apple"
    with open(os.path.join(home, ".file")) as file:
        assert file.read() == "banana"


def test_link_backup_not_created_if_link(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
    """Verify that a backup file isn't created if destination is a symlink."""

    open(os.path.join(home, "file"), "w").close()
    dotfiles.write("file")
    os.symlink(os.path.join(home, "file"), os.path.join(home, ".file"))

    config = [{"link": {"~/.file": {"path": "file", "backup": True}}}]
    dotfiles.write_config(config)
    with pytest.raises(SystemExit):
        run_dotbot()

    assert not os.path.exists(os.path.join(home, ".file.dotbot-backup"))


def test_link_backup_created_if_force(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
    """Verify that backups are created when the force option is used."""

    with open(os.path.join(home, ".file"), "w") as f:
        f.write("apple")
    dotfiles.write("file", "banana")

    config = [{"link": {"~/.file": {"path": "file", "backup": True, "force": True}}}]
    dotfiles.write_config(config)
    run_dotbot()

    backup_files = [f for f in os.listdir(home) if f.startswith(".file.dotbot-backup")]
    assert len(backup_files) == 1
    with open(os.path.join(home, backup_files[0])) as file:
        assert file.read() == "apple"
    with open(os.path.join(home, ".file")) as file:
        assert file.read() == "banana"


def test_link_backup_error_if_dest_already_exists(
    home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]
) -> None:
    """Verify an error is thrown if the backup already exists."""

    os.mkdir(os.path.join(home, ".dir"))
    # create fake backup directories; this test is technically timing dependent but it should work out in practice
    now = datetime.now(timezone.utc).astimezone()
    for delta in range(10):
        timestamp = (now + timedelta(seconds=delta)).strftime("%Y%m%d-%H%M%S")
        os.mkdir(os.path.join(home, f".dir.dotbot-backup.{timestamp}"))
        with open(os.path.join(home, f".dir.dotbot-backup.{timestamp}", "f"), "w") as file:
            file.write("apple")
    dotfiles.write("dir")

    config = [{"link": {"~/.dir": {"path": "dir", "backup": True}}}]
    dotfiles.write_config(config)
    with pytest.raises(SystemExit):
        run_dotbot()

    for delta in range(10):
        timestamp = (now + timedelta(seconds=delta)).strftime("%Y%m%d-%H%M%S")
        with open(os.path.join(home, f".dir.dotbot-backup.{timestamp}", "f")) as file:
            assert file.read() == "apple"


def test_link_backup_glob(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
    """Verify that backup works with globbing."""
    dotfiles.write("bin/a", "apple")
    dotfiles.write("bin/b", "banana")
    dotfiles.write("bin/c", "cherry")

    os.mkdir(os.path.join(home, "bin"))
    with open(os.path.join(home, "bin", "a"), "w") as f:
        f.write("apricot")
    with open(os.path.join(home, "bin", "b"), "w") as f:
        f.write("blueberry")
    with open(os.path.join(home, "bin", "c"), "w") as f:
        f.write("cranberry")

    config = [
        {
            "defaults": {"link": {"glob": True, "create": True, "backup": True}},
        },
        {
            "link": {"~/bin": "bin/*"},
        },
    ]
    dotfiles.write_config(config)
    run_dotbot()

    backup_a = [f for f in os.listdir(os.path.join(home, "bin")) if f.startswith("a.dotbot-backup")]
    backup_b = [f for f in os.listdir(os.path.join(home, "bin")) if f.startswith("b.dotbot-backup")]
    backup_c = [f for f in os.listdir(os.path.join(home, "bin")) if f.startswith("c.dotbot-backup")]
    assert len(backup_a) == 1
    assert len(backup_b) == 1
    assert len(backup_c) == 1
    with open(os.path.join(home, "bin", backup_a[0])) as file:
        assert file.read() == "apricot"
    with open(os.path.join(home, "bin", backup_b[0])) as file:
        assert file.read() == "blueberry"
    with open(os.path.join(home, "bin", backup_c[0])) as file:
        assert file.read() == "cranberry"

    with open(os.path.join(home, "bin", "a")) as file:
        assert file.read() == "apple"
    with open(os.path.join(home, "bin", "b")) as file:
        assert file.read() == "banana"
    with open(os.path.join(home, "bin", "c")) as file:
        assert file.read() == "cherry"


def test_link_backup_dry_run(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
    """Verify that a backup file is not created if running in dry-run mode."""

    with open(os.path.join(home, ".file"), "w") as f:
        f.write("apple")
    dotfiles.write("file", "banana")

    config = [{"link": {"~/.file": {"path": "file", "backup": True}}}]
    dotfiles.write_config(config)
    run_dotbot("-n")

    backup_files = [f for f in os.listdir(home) if f.startswith(".file.dotbot-backup")]
    assert len(backup_files) == 0


@pytest.mark.parametrize("option", ["relink", "force"])
def test_link_backup_relink_force_with_existing_incorrect_symlink(
    option: str,
    home: str,
    dotfiles: Dotfiles,
    run_dotbot: Callable[..., None],
) -> None:
    """Verify that backup + relink/force replaces an incorrect symlink.

    When an incorrect symlink exists at the destination, backup should not
    prevent the symlink from being deleted and recreated correctly.
    """

    dotfiles.write("f", "apple")
    # Create an incorrect symlink at the destination.
    wrong_target = os.path.join(home, "wrong_target")
    open(wrong_target, "w").close()
    os.symlink(wrong_target, os.path.join(home, ".f"))

    config = [{"link": {"~/.f": {"path": "f", "backup": True, option: True}}}]
    dotfiles.write_config(config)
    run_dotbot()

    # The symlink should now point to the correct target.
    with open(os.path.join(home, ".f")) as file:
        assert file.read() == "apple"
    # Symlinks are not backed up (backup only applies to real files).
    backup_files = [f for f in os.listdir(home) if f.startswith(".f.dotbot-backup")]
    assert len(backup_files) == 0


def test_link_backup_relink_real_file_skips_delete(
    home: str,
    dotfiles: Dotfiles,
    run_dotbot: Callable[..., None],
) -> None:
    """Verify that _delete is not called when backup already moved a real file.

    When backup successfully renames a real file and relink is set,
    there's no need to call _delete: the file is already gone.
    """

    dotfiles.write("f", "apple")
    with open(os.path.join(home, ".f"), "w") as f:
        f.write("banana")

    config = [{"link": {"~/.f": {"path": "f", "backup": True, "relink": True}}}]
    dotfiles.write_config(config)

    original_delete = Link._delete  # noqa: SLF001
    delete_was_called = False

    def tracking_delete(self: Any, *args: Any, **kwargs: Any) -> Any:
        nonlocal delete_was_called
        delete_was_called = True
        return original_delete(self, *args, **kwargs)

    with patch.object(Link, "_delete", tracking_delete):
        run_dotbot()

    assert not delete_was_called

    with open(os.path.join(home, ".f")) as file:
        assert file.read() == "apple"
    backup_files = [f for f in os.listdir(home) if f.startswith(".f.dotbot-backup")]
    assert len(backup_files) == 1
    with open(os.path.join(home, backup_files[0])) as file:
        assert file.read() == "banana"


def test_link_backup_relink_with_existing_incorrect_symlink_glob(
    home: str,
    dotfiles: Dotfiles,
    run_dotbot: Callable[..., None],
) -> None:
    """Verify that backup + relink replaces incorrect symlinks with globbing."""

    dotfiles.write("bin/a", "apple")
    dotfiles.write("bin/b", "banana")

    os.makedirs(os.path.join(home, "bin"))
    # Create incorrect symlinks at the destinations.
    wrong_target = os.path.join(home, "wrong_target")
    open(wrong_target, "w").close()
    os.symlink(wrong_target, os.path.join(home, "bin", "a"))
    os.symlink(wrong_target, os.path.join(home, "bin", "b"))

    config = [
        {"defaults": {"link": {"glob": True, "create": True, "backup": True, "relink": True}}},
        {"link": {"~/bin": "bin/*"}},
    ]
    dotfiles.write_config(config)
    run_dotbot()

    with open(os.path.join(home, "bin", "a")) as file:
        assert file.read() == "apple"
    with open(os.path.join(home, "bin", "b")) as file:
        assert file.read() == "banana"


def test_link_glob_1(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
    """Verify globbing works."""

    dotfiles.write("bin/a", "apple")
    dotfiles.write("bin/b", "banana")
    dotfiles.write("bin/c", "cherry")
    dotfiles.write_config(
        [
            {"defaults": {"link": {"glob": True, "create": True}}},
            {"link": {"~/bin": "bin/*"}},
        ]
    )
    run_dotbot()

    with open(os.path.join(home, "bin", "a")) as file:
        assert file.read() == "apple"
    with open(os.path.join(home, "bin", "b")) as file:
        assert file.read() == "banana"
    with open(os.path.join(home, "bin", "c")) as file:
        assert file.read() == "cherry"


def test_link_glob_2(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
    """Verify globbing works with a trailing slash in the target."""

    dotfiles.write("bin/a", "apple")
    dotfiles.write("bin/b", "banana")
    dotfiles.write("bin/c", "cherry")
    dotfiles.write_config(
        [
            {"defaults": {"link": {"glob": True, "create": True}}},
            {"link": {"~/bin/": "bin/*"}},
        ]
    )
    run_dotbot()

    with open(os.path.join(home, "bin", "a")) as file:
        assert file.read() == "apple"
    with open(os.path.join(home, "bin", "b")) as file:
        assert file.read() == "banana"
    with open(os.path.join(home, "bin", "c")) as file:
        assert file.read() == "cherry"


def test_link_glob_3(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
    """Verify globbing works with hidden ("period-prefixed") files."""

    dotfiles.write("bin/.a", "dot-apple")
    dotfiles.write("bin/.b", "dot-banana")
    dotfiles.write("bin/.c", "dot-cherry")
    dotfiles.write_config(
        [
            {"defaults": {"link": {"glob": True, "create": True}}},
            {"link": {"~/bin/": "bin/.*"}},
        ]
    )
    run_dotbot()

    with open(os.path.join(home, "bin", ".a")) as file:
        assert file.read() == "dot-apple"
    with open(os.path.join(home, "bin", ".b")) as file:
        assert file.read() == "dot-banana"
    with open(os.path.join(home, "bin", ".c")) as file:
        assert file.read() == "dot-cherry"


def test_link_glob_4(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
    """Verify globbing works at the root of the home and dotfiles directories."""

    dotfiles.write(".a", "dot-apple")
    dotfiles.write(".b", "dot-banana")
    dotfiles.write(".c", "dot-cherry")
    dotfiles.write_config(
        [
            {
                "link": {
                    "~": {
                        "path": ".*",
                        "glob": True,
                    },
                },
            }
        ]
    )
    run_dotbot()

    with open(os.path.join(home, ".a")) as file:
        assert file.read() == "dot-apple"
    with open(os.path.join(home, ".b")) as file:
        assert file.read() == "dot-banana"
    with open(os.path.join(home, ".c")) as file:
        assert file.read() == "dot-cherry"


def test_link_glob_force(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
    """Verify that glob/force work together."""

    os.mkdir(os.path.join(home, "bin"))
    with open(os.path.join(home, "bin", "a"), "w") as f:
        f.write("apricot")
    with open(os.path.join(home, "bin", "b"), "w") as f:
        f.write("blueberry")
    with open(os.path.join(home, "bin", "c"), "w") as f:
        f.write("cranberry")

    dotfiles.write("bin/a", "apple")
    dotfiles.write("bin/b", "banana")
    dotfiles.write("bin/c", "cherry")
    dotfiles.write_config(
        [
            {"defaults": {"link": {"glob": True, "create": True, "force": True}}},
            {"link": {"~/bin": "bin/*"}},
        ]
    )
    run_dotbot()

    with open(os.path.join(home, "bin", "a")) as file:
        assert file.read() == "apple"
    with open(os.path.join(home, "bin", "b")) as file:
        assert file.read() == "banana"
    with open(os.path.join(home, "bin", "c")) as file:
        assert file.read() == "cherry"


@pytest.mark.parametrize("path", ["foo", "foo/"])
def test_link_glob_ignore_no_glob_chars(
    path: str, home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]
) -> None:
    """Verify ambiguous link globbing fails."""

    dotfiles.makedirs("foo")
    dotfiles.write_config(
        [
            {
                "link": {
                    "~/foo/": {
                        "path": path,
                        "glob": True,
                    }
                }
            }
        ]
    )
    run_dotbot()
    assert os.path.islink(os.path.join(home, "foo"))
    assert os.path.exists(os.path.join(home, "foo"))


def test_link_glob_exclude_1(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
    """Verify link globbing with an explicit exclusion."""

    dotfiles.write("config/foo/a", "apple")
    dotfiles.write("config/bar/b", "banana")
    dotfiles.write("config/bar/c", "cherry")
    dotfiles.write("config/baz/d", "donut")
    dotfiles.write_config(
        [
            {
                "defaults": {
                    "link": {
                        "glob": True,
                        "create": True,
                    },
                },
            },
            {
                "link": {
                    "~/.config/": {
                        "path": "config/*",
                        "exclude": ["config/baz"],
                    },
                },
            },
        ]
    )
    run_dotbot()

    assert not os.path.exists(os.path.join(home, ".config", "baz"))

    assert not os.path.islink(os.path.join(home, ".config"))
    assert os.path.islink(os.path.join(home, ".config", "foo"))
    assert os.path.islink(os.path.join(home, ".config", "bar"))
    with open(os.path.join(home, ".config", "foo", "a")) as file:
        assert file.read() == "apple"
    with open(os.path.join(home, ".config", "bar", "b")) as file:
        assert file.read() == "banana"
    with open(os.path.join(home, ".config", "bar", "c")) as file:
        assert file.read() == "cherry"


def test_link_glob_exclude_2(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
    """Verify deep link globbing with a globbed exclusion."""

    dotfiles.write("config/foo/a", "apple")
    dotfiles.write("config/bar/b", "banana")
    dotfiles.write("config/bar/c", "cherry")
    dotfiles.write("config/baz/d", "donut")
    dotfiles.write("config/baz/buzz/e", "egg")
    dotfiles.write_config(
        [
            {
                "defaults": {
                    "link": {
                        "glob": True,
                        "create": True,
                    },
                },
            },
            {
                "link": {
                    "~/.config/": {
                        "path": "config/*/*",
                        "exclude": ["config/baz/*"],
                    },
                },
            },
        ]
    )
    run_dotbot()

    assert not os.path.exists(os.path.join(home, ".config", "baz"))

    assert not os.path.islink(os.path.join(home, ".config"))
    assert not os.path.islink(os.path.join(home, ".config", "foo"))
    assert not os.path.islink(os.path.join(home, ".config", "bar"))
    assert os.path.islink(os.path.join(home, ".config", "foo", "a"))
    with open(os.path.join(home, ".config", "foo", "a")) as file:
        assert file.read() == "apple"
    with open(os.path.join(home, ".config", "bar", "b")) as file:
        assert file.read() == "banana"
    with open(os.path.join(home, ".config", "bar", "c")) as file:
        assert file.read() == "cherry"


def test_link_glob_exclude_3(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
    """Verify deep link globbing with an explicit exclusion."""

    dotfiles.write("config/foo/a", "apple")
    dotfiles.write("config/bar/b", "banana")
    dotfiles.write("config/bar/c", "cherry")
    dotfiles.write("config/baz/d", "donut")
    dotfiles.write("config/baz/buzz/e", "egg")
    dotfiles.write("config/baz/bizz/g", "grape")
    dotfiles.write_config(
        [
            {
                "defaults": {
                    "link": {
                        "glob": True,
                        "create": True,
                    },
                },
            },
            {
                "link": {
                    "~/.config/": {
                        "path": "config/*/*",
                        "exclude": ["config/baz/buzz"],
                    },
                },
            },
        ]
    )
    run_dotbot()

    assert not os.path.exists(os.path.join(home, ".config", "baz", "buzz"))

    assert not os.path.islink(os.path.join(home, ".config"))
    assert not os.path.islink(os.path.join(home, ".config", "foo"))
    assert not os.path.islink(os.path.join(home, ".config", "bar"))
    assert not os.path.islink(os.path.join(home, ".config", "baz"))
    assert os.path.islink(os.path.join(home, ".config", "baz", "bizz"))
    assert os.path.islink(os.path.join(home, ".config", "foo", "a"))
    with open(os.path.join(home, ".config", "foo", "a")) as file:
        assert file.read() == "apple"
    with open(os.path.join(home, ".config", "bar", "b")) as file:
        assert file.read() == "banana"
    with open(os.path.join(home, ".config", "bar", "c")) as file:
        assert file.read() == "cherry"
    with open(os.path.join(home, ".config", "baz", "d")) as file:
        assert file.read() == "donut"
    with open(os.path.join(home, ".config", "baz", "bizz", "g")) as file:
        assert file.read() == "grape"


def test_link_glob_exclude_4(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
    """Verify deep link globbing with multiple globbed exclusions."""

    dotfiles.write("config/foo/a", "apple")
    dotfiles.write("config/bar/b", "banana")
    dotfiles.write("config/bar/c", "cherry")
    dotfiles.write("config/baz/d", "donut")
    dotfiles.write("config/baz/buzz/e", "egg")
    dotfiles.write("config/baz/bizz/g", "grape")
    dotfiles.write("config/fiz/f", "fig")
    dotfiles.write_config(
        [
            {
                "defaults": {
                    "link": {
                        "glob": True,
                        "create": True,
                    },
                },
            },
            {
                "link": {
                    "~/.config/": {
                        "path": "config/*/*",
                        "exclude": ["config/baz/*", "config/fiz/*"],
                    },
                },
            },
        ]
    )
    run_dotbot()

    assert not os.path.exists(os.path.join(home, ".config", "baz"))
    assert not os.path.exists(os.path.join(home, ".config", "fiz"))

    assert not os.path.islink(os.path.join(home, ".config"))
    assert not os.path.islink(os.path.join(home, ".config", "foo"))
    assert not os.path.islink(os.path.join(home, ".config", "bar"))
    assert os.path.islink(os.path.join(home, ".config", "foo", "a"))
    with open(os.path.join(home, ".config", "foo", "a")) as file:
        assert file.read() == "apple"
    with open(os.path.join(home, ".config", "bar", "b")) as file:
        assert file.read() == "banana"
    with open(os.path.join(home, ".config", "bar", "c")) as file:
        assert file.read() == "cherry"


def test_link_glob_multi_star(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
    """Verify link globbing with deep-nested stars."""

    dotfiles.write("config/foo/a", "apple")
    dotfiles.write("config/bar/b", "banana")
    dotfiles.write("config/bar/c", "cherry")
    dotfiles.write_config(
        [
            {"defaults": {"link": {"glob": True, "create": True}}},
            {"link": {"~/.config/": "config/*/*"}},
        ]
    )
    run_dotbot()

    assert not os.path.islink(os.path.join(home, ".config"))
    assert not os.path.islink(os.path.join(home, ".config", "foo"))
    assert not os.path.islink(os.path.join(home, ".config", "bar"))
    assert os.path.islink(os.path.join(home, ".config", "foo", "a"))
    with open(os.path.join(home, ".config", "foo", "a")) as file:
        assert file.read() == "apple"
    with open(os.path.join(home, ".config", "bar", "b")) as file:
        assert file.read() == "banana"
    with open(os.path.join(home, ".config", "bar", "c")) as file:
        assert file.read() == "cherry"


@pytest.mark.parametrize(
    ("pattern", "expect_file"),
    [
        ("conf/*", lambda fruit: fruit),
        ("conf/.*", lambda fruit: "." + fruit),
        ("conf/[bc]*", lambda fruit: fruit if fruit[0] in "bc" else None),
        ("conf/*e", lambda fruit: fruit if fruit[-1] == "e" else None),
        ("conf/??r*", lambda fruit: fruit if fruit[2] == "r" else None),
    ],
)
def test_link_glob_patterns(
    pattern: str,
    expect_file: Callable[[str], Optional[str]],
    home: str,
    dotfiles: Dotfiles,
    run_dotbot: Callable[..., None],
) -> None:
    """Verify link glob pattern matching."""

    fruits = ["apple", "apricot", "banana", "cherry", "currant", "cantalope"]
    for fruit in fruits:
        dotfiles.write("conf/" + fruit, fruit)
        dotfiles.write("conf/." + fruit, "dot-" + fruit)
    dotfiles.write_config(
        [
            {"defaults": {"link": {"glob": True, "create": True}}},
            {"link": {"~/globtest": pattern}},
        ]
    )
    run_dotbot()

    for fruit in fruits:
        expected = expect_file(fruit)
        if expected is None:
            assert not os.path.exists(os.path.join(home, "globtest", fruit))
            assert not os.path.exists(os.path.join(home, "globtest", "." + fruit))
        elif "." in expected:
            assert not os.path.islink(os.path.join(home, "globtest", fruit))
            assert os.path.islink(os.path.join(home, "globtest", "." + fruit))
        else:  # "." not in expected
            assert os.path.islink(os.path.join(home, "globtest", fruit))
            assert not os.path.islink(os.path.join(home, "globtest", "." + fruit))


def test_link_glob_recursive(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
    """Verify recursive link globbing and exclusions."""

    dotfiles.write("config/foo/bar/a", "apple")
    dotfiles.write("config/foo/bar/b", "banana")
    dotfiles.write("config/foo/bar/c", "cherry")
    dotfiles.write_config(
        [
            {"defaults": {"link": {"glob": True, "create": True}}},
            {"link": {"~/.config/": {"path": "config/**", "exclude": ["config/**/b"]}}},
        ]
    )
    run_dotbot()

    assert not os.path.islink(os.path.join(home, ".config"))
    assert not os.path.islink(os.path.join(home, ".config", "foo"))
    assert not os.path.islink(os.path.join(home, ".config", "foo", "bar"))
    assert os.path.islink(os.path.join(home, ".config", "foo", "bar", "a"))
    assert not os.path.exists(os.path.join(home, ".config", "foo", "bar", "b"))
    assert os.path.islink(os.path.join(home, ".config", "foo", "bar", "c"))
    with open(os.path.join(home, ".config", "foo", "bar", "a")) as file:
        assert file.read() == "apple"
    with open(os.path.join(home, ".config", "foo", "bar", "c")) as file:
        assert file.read() == "cherry"


def test_link_glob_no_match(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
    """Verify that a glob with no match doesn't raise an error."""

    _ = home
    dotfiles.makedirs("foo")
    dotfiles.write_config(
        [
            {"defaults": {"link": {"glob": True, "create": True}}},
            {"link": {"~/.config/foo": "foo/*"}},
        ]
    )
    run_dotbot()


def test_link_glob_single_match(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
    """Verify linking works even when glob matches exactly one file."""
    # regression test for https://github.com/anishathalye/dotbot/issues/282

    dotfiles.write("foo/a", "apple")
    dotfiles.write_config(
        [
            {"defaults": {"link": {"glob": True, "create": True}}},
            {"link": {"~/.config/foo": "foo/*"}},
        ]
    )
    run_dotbot()

    assert not os.path.islink(os.path.join(home, ".config"))
    assert not os.path.islink(os.path.join(home, ".config", "foo"))
    assert os.path.islink(os.path.join(home, ".config", "foo", "a"))
    with open(os.path.join(home, ".config", "foo", "a")) as file:
        assert file.read() == "apple"


@pytest.mark.skipif(
    "sys.platform == 'win32'",
    reason="These if commands won't run on Windows",
)
def test_link_if(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
    """Verify 'if' directives are checked when linking."""

    os.mkdir(os.path.join(home, "d"))
    dotfiles.write("f", "apple")
    dotfiles.write_config(
        [
            {
                "link": {
                    "~/.f": {"path": "f", "if": "true"},
                    "~/.g": {"path": "f", "if": "false"},
                    "~/.h": {"path": "f", "if": "[ -d ~/d ]"},
                    "~/.i": {"path": "f", "if": "badcommand"},
                },
            }
        ]
    )
    run_dotbot()

    assert not os.path.exists(os.path.join(home, ".g"))
    assert not os.path.exists(os.path.join(home, ".i"))
    with open(os.path.join(home, ".f")) as file:
        assert file.read() == "apple"
    with open(os.path.join(home, ".h")) as file:
        assert file.read() == "apple"


@pytest.mark.skipif(
    "sys.platform == 'win32'",
    reason="These if commands won't run on Windows",
)
def test_link_if_defaults(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
    """Verify 'if' directive defaults are checked when linking."""

    os.mkdir(os.path.join(home, "d"))
    dotfiles.write("f", "apple")
    dotfiles.write_config(
        [
            {
                "defaults": {
                    "link": {
                        "if": "false",
                    },
                },
            },
            {
                "link": {
                    "~/.j": {"path": "f", "if": "true"},
                    "~/.k": {"path": "f"},  # default is false
                },
            },
        ]
    )
    run_dotbot()

    assert not os.path.exists(os.path.join(home, ".k"))
    with open(os.path.join(home, ".j")) as file:
        assert file.read() == "apple"


@pytest.mark.skipif(
    "sys.platform != 'win32'",
    reason="These if commands only run on Windows",
)
def test_link_if_windows(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
    """Verify 'if' directives are checked when linking (Windows only)."""

    os.mkdir(os.path.join(home, "d"))
    dotfiles.write("f", "apple")
    dotfiles.write_config(
        [
            {
                "link": {
                    "~/.f": {"path": "f", "if": 'cmd /c "exit 0"'},
                    "~/.g": {"path": "f", "if": 'cmd /c "exit 1"'},
                    "~/.h": {"path": "f", "if": 'cmd /c "dir %USERPROFILE%\\d'},
                    "~/.i": {"path": "f", "if": 'cmd /c "badcommand"'},
                },
            }
        ]
    )
    run_dotbot()

    assert not os.path.exists(os.path.join(home, ".g"))
    assert not os.path.exists(os.path.join(home, ".i"))
    with open(os.path.join(home, ".f")) as file:
        assert file.read() == "apple"
    with open(os.path.join(home, ".h")) as file:
        assert file.read() == "apple"


@pytest.mark.skipif(
    "sys.platform != 'win32'",
    reason="These if commands only run on Windows.",
)
def test_link_if_defaults_windows(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
    """Verify 'if' directive defaults are checked when linking (Windows only)."""

    os.mkdir(os.path.join(home, "d"))
    dotfiles.write("f", "apple")
    dotfiles.write_config(
        [
            {
                "defaults": {
                    "link": {
                        "if": 'cmd /c "exit 1"',
                    },
                },
            },
            {
                "link": {
                    "~/.j": {"path": "f", "if": 'cmd /c "exit 0"'},
                    "~/.k": {"path": "f"},  # default is false
                },
            },
        ]
    )
    run_dotbot()

    assert not os.path.exists(os.path.join(home, ".k"))
    with open(os.path.join(home, ".j")) as file:
        assert file.read() == "apple"


@pytest.mark.parametrize("ignore_missing", [True, False])
def test_link_ignore_missing(
    ignore_missing: bool,  # noqa: FBT001
    home: str,
    dotfiles: Dotfiles,
    run_dotbot: Callable[..., None],
) -> None:
    """Verify link 'ignore_missing' is respected when the target is missing."""

    dotfiles.write_config(
        [
            {
                "link": {
                    "~/missing_link": {
                        "path": "missing",
                        "ignore-missing": ignore_missing,
                    },
                },
            }
        ]
    )

    if ignore_missing:
        run_dotbot()
        assert os.path.islink(os.path.join(home, "missing_link"))
    else:
        with pytest.raises(SystemExit):
            run_dotbot()


def test_link_leaves_file(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
    """Verify relink does not overwrite file."""

    dotfiles.write("f", "apple")
    with open(os.path.join(home, ".f"), "w") as file:
        file.write("grape")
    dotfiles.write_config([{"link": {"~/.f": "f"}}])
    with pytest.raises(SystemExit):
        run_dotbot()

    with open(os.path.join(home, ".f")) as file:
        assert file.read() == "grape"


@pytest.mark.parametrize("key", ["canonicalize-path", "canonicalize"])
def test_link_no_canonicalize(key: str, home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
    """Verify link canonicalization can be disabled."""

    dotfiles.write("f", "apple")
    dotfiles.write_config([{"defaults": {"link": {key: False}}}, {"link": {"~/.f": {"path": "f"}}}])
    os.symlink(
        dotfiles.directory,
        os.path.join(home, "dotfiles-symlink"),
        target_is_directory=True,
    )
    run_dotbot(
        "-c",
        os.path.join(home, "dotfiles-symlink", os.path.basename(dotfiles.config_filename)),
        custom=True,
    )
    assert "dotfiles-symlink" in os.readlink(os.path.join(home, ".f"))


def test_link_prefix(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
    """Verify link prefixes are prepended."""

    dotfiles.write("conf/a", "apple")
    dotfiles.write("conf/b", "banana")
    dotfiles.write("conf/c", "cherry")
    dotfiles.write_config(
        [
            {
                "link": {
                    "~/": {
                        "glob": True,
                        "path": "conf/*",
                        "prefix": ".",
                    },
                },
            }
        ]
    )
    run_dotbot()
    with open(os.path.join(home, ".a")) as file:
        assert file.read() == "apple"
    with open(os.path.join(home, ".b")) as file:
        assert file.read() == "banana"
    with open(os.path.join(home, ".c")) as file:
        assert file.read() == "cherry"


def test_link_relative(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
    """Test relative linking works."""

    dotfiles.write("f", "apple")
    dotfiles.write("d/e", "grape")
    dotfiles.write_config(
        [
            {
                "link": {
                    "~/.f": {
                        "path": "f",
                    },
                    "~/.frel": {
                        "path": "f",
                        "relative": True,
                    },
                    "~/nested/.frel": {
                        "path": "f",
                        "relative": True,
                        "create": True,
                    },
                    "~/.d": {
                        "path": "d",
                        "relative": True,
                    },
                },
            }
        ]
    )
    run_dotbot()

    f = os.readlink(os.path.join(home, ".f"))
    if sys.platform == "win32" and f.startswith("\\\\?\\"):
        f = f[4:]
    assert f == os.path.join(dotfiles.directory, "f")

    frel = os.readlink(os.path.join(home, ".frel"))
    if sys.platform == "win32" and frel.startswith("\\\\?\\"):
        frel = frel[4:]
    assert frel == os.path.normpath("../../dotfiles/f")

    nested_frel = os.readlink(os.path.join(home, "nested", ".frel"))
    if sys.platform == "win32" and nested_frel.startswith("\\\\?\\"):
        nested_frel = nested_frel[4:]
    assert nested_frel == os.path.normpath("../../../dotfiles/f")

    d = os.readlink(os.path.join(home, ".d"))
    if sys.platform == "win32" and d.startswith("\\\\?\\"):
        d = d[4:]
    assert d == os.path.normpath("../../dotfiles/d")

    with open(os.path.join(home, ".f")) as file:
        assert file.read() == "apple"
    with open(os.path.join(home, ".frel")) as file:
        assert file.read() == "apple"
    with open(os.path.join(home, "nested", ".frel")) as file:
        assert file.read() == "apple"
    with open(os.path.join(home, ".d", "e")) as file:
        assert file.read() == "grape"


def test_link_relink_leaves_file(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
    """Verify relink does not overwrite file."""

    dotfiles.write("f", "apple")
    with open(os.path.join(home, ".f"), "w") as file:
        file.write("grape")
    dotfiles.write_config([{"link": {"~/.f": {"path": "f", "relink": True}}}])
    with pytest.raises(SystemExit):
        run_dotbot()
    with open(os.path.join(home, ".f")) as file:
        assert file.read() == "grape"


def test_link_relink_overwrite_symlink(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
    """Verify relink overwrites symlinks."""

    dotfiles.write("f", "apple")
    with open(os.path.join(home, "f"), "w") as file:
        file.write("grape")
    os.symlink(os.path.join(home, "f"), os.path.join(home, ".f"))
    dotfiles.write_config([{"link": {"~/.f": {"path": "f", "relink": True}}}])
    run_dotbot()
    with open(os.path.join(home, ".f")) as file:
        assert file.read() == "apple"


def test_link_relink_relative_leaves_file(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
    """Verify relink relative does not incorrectly relink file."""

    dotfiles.write("f", "apple")
    with open(os.path.join(home, ".f"), "w") as file:
        file.write("grape")
    config = [
        {
            "link": {
                "~/.folder/f": {
                    "path": "f",
                    "create": True,
                    "relative": True,
                },
            },
        }
    ]
    dotfiles.write_config(config)
    run_dotbot()

    mtime = os.stat(os.path.join(home, ".folder", "f")).st_mtime

    config[0]["link"]["~/.folder/f"]["relink"] = True
    dotfiles.write_config(config)
    run_dotbot()

    new_mtime = os.stat(os.path.join(home, ".folder", "f")).st_mtime
    assert mtime == new_mtime


def test_target_is_not_overwritten_by_symlink_trickery(
    capsys: pytest.CaptureFixture[str], home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]
) -> None:
    dotfiles_path = pathlib.Path(dotfiles.directory)
    home_path = pathlib.Path(home)

    # Setup:
    #   *   A symlink exists from `~/.ssh` to `ssh` in the dotfiles directory.
    #   *   Dotbot is configured to force-recreate a symlink between two paths
    #       when, in reality, it's actually the same file when resolved.
    ssh_config = (dotfiles_path / "ssh/config").absolute()
    os.mkdir(str(ssh_config.parent))
    ssh_config.write_text("preserve me!")
    os.symlink(str(ssh_config.parent), str(home_path / ".ssh"))
    dotfiles.write_config(
        [
            {
                "defaults": {
                    "link": {
                        "relink": True,
                        "create": True,
                        "force": True,
                    },
                }
            },
            {
                "link": {
                    # When symlinks are resolved, these are actually the same file.
                    "~/.ssh/config": "ssh/config",
                },
            },
        ]
    )

    # Execute dotbot.
    with pytest.raises(SystemExit):
        run_dotbot()

    stdout, _ = capsys.readouterr()
    assert "appears to be the same file" in stdout
    # Verify that the file was not overwritten.
    assert ssh_config.read_text() == "preserve me!"


def test_link_defaults_1(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
    """Verify that link doesn't overwrite non-dotfiles links by default."""

    with open(os.path.join(home, "f"), "w") as file:
        file.write("grape")
    os.symlink(os.path.join(home, "f"), os.path.join(home, ".f"))
    dotfiles.write("f", "apple")
    dotfiles.write_config(
        [
            {
                "link": {"~/.f": "f"},
            }
        ]
    )
    with pytest.raises(SystemExit):
        run_dotbot()

    with open(os.path.join(home, ".f")) as file:
        assert file.read() == "grape"


def test_link_defaults_2(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
    """Verify that explicit link defaults override the implicit default."""

    with open(os.path.join(home, "f"), "w") as file:
        file.write("grape")
    os.symlink(os.path.join(home, "f"), os.path.join(home, ".f"))
    dotfiles.write("f", "apple")
    dotfiles.write_config(
        [
            {"defaults": {"link": {"relink": True}}},
            {"link": {"~/.f": "f"}},
        ]
    )
    run_dotbot()

    with open(os.path.join(home, ".f")) as file:
        assert file.read() == "apple"


@pytest.mark.parametrize(
    "config",
    [
        pytest.param([{"link": {"~/.f": "f"}}], id="unspecified"),
        pytest.param(
            [{"link": {"~/.f": {"path": "f", "type": "symlink"}}}],
            id="specified",
        ),
        pytest.param(
            [
                {"defaults": {"link": {"type": "symlink"}}},
                {"link": {"~/.f": "f"}},
            ],
            id="symlink set for all links by default",
        ),
    ],
)
def test_link_type_symlink(
    config: List[Dict[str, Any]], home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]
) -> None:
    """Verify that symlinks are created by default, and when specified."""

    dotfiles.write("f", "apple")
    dotfiles.write_config(config)
    run_dotbot()

    assert os.path.islink(os.path.join(home, ".f"))


@pytest.mark.parametrize(
    "config",
    [
        pytest.param(
            [{"link": {"~/.f": {"path": "f", "type": "hardlink"}}}],
            id="specified",
        ),
        pytest.param(
            [
                {"defaults": {"link": {"type": "hardlink"}}},
                {"link": {"~/.f": "f"}},
            ],
            id="hardlink set for all links by default",
        ),
    ],
)
def test_link_type_hardlink(
    config: List[Dict[str, Any]], home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]
) -> None:
    """Verify that hardlinks are created when specified."""

    dotfiles.write("f", "apple")
    assert os.stat(os.path.join(dotfiles.directory, "f")).st_nlink == 1
    dotfiles.write_config(config)
    run_dotbot()

    assert not os.path.islink(os.path.join(home, ".f"))
    assert os.stat(os.path.join(dotfiles.directory, "f")).st_nlink == 2
    assert os.stat(os.path.join(home, ".f")).st_nlink == 2


@pytest.mark.parametrize(
    "config",
    [
        pytest.param(
            [{"defaults": {"link": {"type": "default-bogus"}}, "link": {}}],
            id="default link type not recognized",
        ),
        pytest.param(
            [{"link": {"~/.f": {"type": "specified-bogus"}}}],
            id="specified link type not recognized",
        ),
    ],
)
def test_unknown_link_type(
    capsys: pytest.CaptureFixture[str],
    config: List[Dict[str, Any]],
    dotfiles: Dotfiles,
    run_dotbot: Callable[..., None],
) -> None:
    """Verify that unknown link types are rejected."""

    dotfiles.write_config(config)
    with pytest.raises(SystemExit):
        run_dotbot()
    stdout, _ = capsys.readouterr()
    assert "link type is not recognized" in stdout


def test_symlink_exists_when_hardlink_requested(
    capsys: pytest.CaptureFixture[str],
    home: str,
    dotfiles: Dotfiles,
    run_dotbot: Callable[..., None],
) -> None:
    """Confirm messaging when a symlink exists but a hardlink is requested."""

    # Setup: Create a symlink to the hardlink target.
    dotfiles.write("target", "potato")
    os.symlink(
        os.path.join(dotfiles.directory, "target"),
        os.path.join(home, "hardlink"),
    )

    # Act
    dotfiles.write_config([{"link": {"~/hardlink": {"path": "target", "type": "hardlink"}}}])
    with pytest.raises(SystemExit):
        run_dotbot()

    # Verify
    stdout, _ = capsys.readouterr()
    assert "already exists but is a symbolic link, not a hard link" in stdout


def test_hardlink_already_exists(
    capsys: pytest.CaptureFixture[str],
    home: str,
    dotfiles: Dotfiles,
    run_dotbot: Callable[..., None],
) -> None:
    """Confirm messaging when the hardlink already exists."""

    dotfiles.write("target", "potato")
    os.link(
        os.path.join(dotfiles.directory, "target"),
        os.path.join(home, "hardlink"),
    )

    # Act
    dotfiles.write_config([{"link": {"~/hardlink": {"path": "target", "type": "hardlink"}}}])
    run_dotbot("-v")

    # Verify
    stdout, _ = capsys.readouterr()
    assert "Link exists" in stdout


def test_broken_symlink_shows_invalid_link_message(
    capsys: pytest.CaptureFixture[str],
    home: str,
    dotfiles: Dotfiles,
    run_dotbot: Callable[..., None],
) -> None:
    """Verify that broken symlinks show 'Invalid link' message instead of 'Linking failed'."""

    broken_target = os.path.join(home, "nonexistent")
    link_name = os.path.join(home, ".f")
    os.symlink(broken_target, link_name)

    dotfiles.write("f", "apple")
    dotfiles.write_config([{"link": {"~/.f": "f"}}])

    with pytest.raises(SystemExit):
        run_dotbot()

    stdout, _ = capsys.readouterr()
    assert "Invalid link" in stdout
    assert "Linking failed" not in stdout


def test_link_dry_run(
    capfd: pytest.CaptureFixture[str], home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]
) -> None:
    """Verify that the link plugin does not create links during a dry run."""

    dotfiles.write("f", "apple")
    dotfiles.write("g", "pear")
    dotfiles.write("h", "banana")
    os.symlink(os.path.join(dotfiles.directory, "g"), os.path.join(home, ".g"))
    dotfiles.write_config(
        [{"link": {"~/.f": "f", "~/.g": {"path": None, "relink": True}, "~/.h": {"path": "h", "type": "hardlink"}}}]
    )
    run_dotbot("-n", "-v")

    assert not os.path.exists(os.path.join(home, ".f"))

    lines = capfd.readouterr().out.splitlines()
    assert any(
        f"Link exists {os.path.join('~', '.g')} -> {os.path.join(dotfiles.directory, 'g')}" == line.strip()
        for line in lines
    )
    assert any(
        f"Would create symlink {os.path.join('~', '.f')} -> {os.path.join(dotfiles.directory, 'f')}" == line.strip()
        for line in lines
    )
    assert any(
        f"Would create hardlink {os.path.join('~', '.h')} -> {os.path.join(dotfiles.directory, 'h')}" == line.strip()
        for line in lines
    )


def test_link_dry_run_if(
    capfd: pytest.CaptureFixture[str], home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]
) -> None:
    """Verify that the link plugin does run condition checks during a dry run."""

    dotfiles.write("f", "apple")
    dotfiles.write("g", "pear")
    dotfiles.write_config(
        [
            {
                "link": {
                    "~/.f": {
                        "path": "f",
                        "if": "touch " + os.path.join(home, "side_effect"),
                    },
                    "~/.g": {
                        "path": "g",
                        "if": "false",
                    },
                }
            }
        ]
    )
    run_dotbot("-n")

    assert os.path.exists(os.path.join(home, "side_effect"))
    assert not os.path.exists(os.path.join(home, ".f"))

    lines = capfd.readouterr().out.splitlines()
    assert any(
        f"Would create symlink {os.path.join('~', '.f')} -> {os.path.join(dotfiles.directory, 'f')}" == line.strip()
        for line in lines
    )
    assert not any("Would create symlink {os.path.join('~', '.g')}" in line for line in lines)


def test_link_dry_run_create(
    capfd: pytest.CaptureFixture[str], home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]
) -> None:
    """Verify that the link plugin does not create parent directories during a dry run."""

    dotfiles.write("f", "apple")
    dotfiles.write_config(
        [
            {
                "link": {
                    "~/.config/.f": {
                        "path": "f",
                        "create": True,
                    }
                }
            }
        ]
    )
    run_dotbot("-n")
    assert not os.path.exists(os.path.join(home, ".config"))
    assert not os.path.exists(os.path.join(home, ".config", ".f"))

    lines = capfd.readouterr().out.splitlines()
    assert any(line.strip() == f"Would create directory {os.path.join(home, '.config')}" for line in lines)
    assert any(
        f"Would create symlink {os.path.join('~', '.config', '.f')} -> {os.path.join(dotfiles.directory, 'f')}"
        == line.strip()
        for line in lines
    )


def test_link_dry_run_relink(
    capfd: pytest.CaptureFixture[str], home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]
) -> None:
    """Verify that the link plugin does not relink existing links during a dry run."""

    dotfiles.write("f", "apple")
    dotfiles.write("g", "pear")
    dotfiles.write_config(
        [
            {
                "link": {
                    "~/.f": {
                        "path": "f",
                        "relink": True,
                    }
                }
            }
        ]
    )
    os.symlink(os.path.join(dotfiles.directory, "g"), os.path.join(home, ".f"))
    run_dotbot("-n")
    with open(os.path.join(home, ".f")) as file:
        assert file.read() == "pear"

    lines = capfd.readouterr().out.splitlines()
    assert any(line.strip() == f"Would remove {os.path.join('~', '.f')}" for line in lines)
    assert any(
        f"Would create symlink {os.path.join('~', '.f')} -> {os.path.join(dotfiles.directory, 'f')}" == line.strip()
        for line in lines
    )


def test_link_dry_run_overwrite(
    capfd: pytest.CaptureFixture[str], home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]
) -> None:
    """Verify that the link plugin does not delete existing files during a dry run."""

    dotfiles.write("f", "apple")
    dotfiles.write_config(
        [
            {
                "link": {
                    "~/.f": {
                        "path": "f",
                        "force": True,
                    }
                }
            }
        ]
    )
    with open(os.path.join(home, ".f"), "w") as file:
        file.write("pear")
    run_dotbot("-n")
    with open(os.path.join(home, ".f")) as file:
        assert file.read() == "pear"

    lines = capfd.readouterr().out.splitlines()
    assert any(line.strip() == f"Would remove {os.path.join('~', '.f')}" for line in lines)
    assert any(
        f"Would create symlink {os.path.join('~', '.f')} -> {os.path.join(dotfiles.directory, 'f')}" == line.strip()
        for line in lines
    )


@pytest.mark.skipif(
    "sys.platform == 'win32'",
    reason="Permissions work differently on Windows",
)
@pytest.mark.skipif(
    "hasattr(os, 'getuid') and os.getuid() == 0",
    reason="Root bypasses permission checks",
)
def test_link_error_creating_link(
    capsys: pytest.CaptureFixture[str], home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]
) -> None:
    """Verify that link reports link creation errors."""

    os.makedirs(os.path.join(home, "subdir"))
    dotfiles.write("f", "apple")
    dotfiles.write_config([{"link": {"~/subdir/.f": "f"}}])

    # Remove all write permissions from subdir.
    old_permissions = stat.S_IMODE(os.stat(os.path.join(home, "subdir")).st_mode)
    os.chmod(
        os.path.join(home, "subdir"),
        stat.S_IRUSR | stat.S_IXUSR | stat.S_IRGRP | stat.S_IXGRP | stat.S_IROTH | stat.S_IXOTH,
    )

    with pytest.raises(SystemExit):
        run_dotbot()

    # Restore permissions to allow test cleanup.
    os.chmod(os.path.join(home, "subdir"), old_permissions)

    stdout, _ = capsys.readouterr()
    assert "Linking failed" in stdout


@pytest.mark.skipif(
    "sys.platform == 'win32'",
    reason="Permissions work differently on Windows",
)
@pytest.mark.skipif(
    "hasattr(os, 'getuid') and os.getuid() == 0",
    reason="Root bypasses permission checks",
)
def test_link_error_creating_directory(
    capsys: pytest.CaptureFixture[str], home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]
) -> None:
    """Verify that link reports directory creation errors."""

    os.makedirs(os.path.join(home, "subdir"))
    dotfiles.write("f", "apple")
    dotfiles.write_config([{"link": {"~/subdir/subsubdir/.f": {"path": "f", "create": True}}}])

    # Remove all write permissions from subdir.
    old_permissions = stat.S_IMODE(os.stat(os.path.join(home, "subdir")).st_mode)
    os.chmod(
        os.path.join(home, "subdir"),
        stat.S_IRUSR | stat.S_IXUSR | stat.S_IRGRP | stat.S_IXGRP | stat.S_IROTH | stat.S_IXOTH,
    )

    with pytest.raises(SystemExit):
        run_dotbot()

    # Restore permissions to allow test cleanup.
    os.chmod(os.path.join(home, "subdir"), old_permissions)

    stdout, _ = capsys.readouterr()
    assert "Failed to create directory" in stdout


@pytest.mark.skipif(
    "sys.platform == 'win32'",
    reason="Permissions work differently on Windows",
)
@pytest.mark.skipif(
    "hasattr(os, 'getuid') and os.getuid() == 0",
    reason="Root bypasses permission checks",
)
def test_link_error_delete(
    capsys: pytest.CaptureFixture[str], home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]
) -> None:
    """Verify that link reports deletion errors."""

    os.makedirs(os.path.join(home, "subdir"))
    with open(os.path.join(home, "subdir", ".f"), "w") as file:
        file.write("grape")
    dotfiles.write("f", "apple")
    dotfiles.write_config([{"link": {"~/subdir/.f": {"path": "f", "force": True}}}])

    # Remove all write permissions from subdir.
    old_permissions = stat.S_IMODE(os.stat(os.path.join(home, "subdir")).st_mode)
    os.chmod(
        os.path.join(home, "subdir"),
        stat.S_IRUSR | stat.S_IXUSR | stat.S_IRGRP | stat.S_IXGRP | stat.S_IROTH | stat.S_IXOTH,
    )

    with pytest.raises(SystemExit):
        run_dotbot()

    # Restore permissions to allow test cleanup.
    os.chmod(os.path.join(home, "subdir"), old_permissions)

    stdout, _ = capsys.readouterr()
    assert "Failed to remove" in stdout


================================================
FILE: tests/test_noop.py
================================================
import os

import pytest


def test_success(root: str) -> None:
    path = os.path.join(root, "abc.txt")
    with open(path, "w") as f:
        f.write("hello")
    with open(path) as f:
        assert f.read() == "hello"


def test_failure() -> None:
    with pytest.raises(AssertionError):
        open("abc.txt", "w")  # noqa: SIM115

    with pytest.raises(AssertionError):
        open(file="abc.txt", mode="w")  # noqa: SIM115

    with pytest.raises(AssertionError):
        os.mkdir("a")

    with pytest.raises(AssertionError):
        os.mkdir(path="a")


================================================
FILE: tests/test_plugins.py
================================================
import os
import shutil
from typing import Callable

import pytest

from tests.conftest import Dotfiles


def test_plugin_file(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., None]) -> None:
    """Verify that a plugin file can be loaded in the config."""

    plugin_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), "dotbot_plugin_file.py")
    shutil.copy(plugin_file, os.path.join(dotfiles.directory, "file.py"))
    dotfiles.write_config(
        [
            {"plugins": ["file.py"]},
            {"plugin_file": "no-check-context"},
        ]
    )
    run_dotbot()
    with open(os.path.join(home, "flag-file")) as file:
        assert file.read() == "file plugin load
Download .txt
gitextract_is1qblwr/

├── .ci/
│   └── check-changelog.py
├── .editorconfig
├── .github/
│   ├── dependabot.yaml
│   └── workflows/
│       ├── ci.yml
│       └── release.yml
├── .gitignore
├── .gitmodules
├── CHANGELOG.md
├── CONTRIBUTING.md
├── DEVELOPMENT.md
├── LICENSE.md
├── README.md
├── bin/
│   └── dotbot
├── codecov.yml
├── pyproject.toml
├── src/
│   └── dotbot/
│       ├── __about__.py
│       ├── __init__.py
│       ├── cli.py
│       ├── config.py
│       ├── context.py
│       ├── dispatcher.py
│       ├── messenger/
│       │   ├── __init__.py
│       │   ├── color.py
│       │   ├── level.py
│       │   └── messenger.py
│       ├── plugin.py
│       ├── plugins/
│       │   ├── __init__.py
│       │   ├── clean.py
│       │   ├── create.py
│       │   ├── link.py
│       │   └── shell.py
│       └── util/
│           ├── __init__.py
│           ├── common.py
│           ├── module.py
│           ├── singleton.py
│           └── string.py
├── tests/
│   ├── __init__.py
│   ├── conftest.py
│   ├── dotbot_plugin_context_plugin.py
│   ├── dotbot_plugin_counter.py
│   ├── dotbot_plugin_directory.py
│   ├── dotbot_plugin_dispatcher_no_plugins.py
│   ├── dotbot_plugin_dry_run.py
│   ├── dotbot_plugin_file.py
│   ├── dotbot_plugin_issue_357.py
│   ├── test_bin_dotbot.py
│   ├── test_clean.py
│   ├── test_cli.py
│   ├── test_config.py
│   ├── test_create.py
│   ├── test_link.py
│   ├── test_noop.py
│   ├── test_plugins.py
│   ├── test_shell.py
│   └── test_shim.py
└── tools/
    ├── git-submodule/
    │   ├── install
    │   └── install.ps1
    └── hg-subrepo/
        ├── install
        └── install.ps1
Download .txt
SYMBOL INDEX (264 symbols across 35 files)

FILE: .ci/check-changelog.py
  function main (line 11) | def main() -> None:
  function get_git_minor_versions (line 32) | def get_git_minor_versions() -> Set[str]:
  function get_changelog_versions (line 46) | def get_changelog_versions(changelog_path: str) -> Set[str]:
  function version_to_tuple (line 56) | def version_to_tuple(version: str) -> Tuple[int, ...]:

FILE: src/dotbot/cli.py
  function add_options (line 15) | def add_options(parser: ArgumentParser) -> None:
  function read_config (line 64) | def read_config(config_files: List[str]) -> Any:
  function main (line 69) | def main() -> None:

FILE: src/dotbot/config.py
  class ConfigReader (line 10) | class ConfigReader:
    method __init__ (line 13) | def __init__(self, config_file_paths: List[str]):
    method _read (line 24) | def _read(self, config_file_path: str) -> Any:
    method get_config (line 34) | def get_config(self) -> Any:
  class ReadingError (line 38) | class ReadingError(Exception):

FILE: src/dotbot/context.py
  class Context (line 10) | class Context:
    method __init__ (line 15) | def __init__(
    method set_base_directory (line 23) | def set_base_directory(self, base_directory: str) -> None:
    method base_directory (line 26) | def base_directory(self, canonical_path: bool = True) -> str:  # noqa:...
    method set_defaults (line 32) | def set_defaults(self, defaults: Dict[str, Any]) -> None:
    method defaults (line 35) | def defaults(self) -> Dict[str, Any]:
    method options (line 38) | def options(self) -> Namespace:
    method plugins (line 41) | def plugins(self) -> "Optional[List[Type[Plugin]]]":
    method dry_run (line 45) | def dry_run(self) -> bool:

FILE: src/dotbot/dispatcher.py
  class Dispatcher (line 19) | class Dispatcher:
    method __init__ (line 20) | def __init__(
    method _setup_context (line 41) | def _setup_context(
    method dispatch (line 50) | def dispatch(self, tasks: List[Dict[str, Any]]) -> bool:
  class DispatchError (line 114) | class DispatchError(Exception):

FILE: src/dotbot/messenger/color.py
  class Color (line 1) | class Color:

FILE: src/dotbot/messenger/level.py
  class Level (line 5) | class Level(Enum):
    method __lt__ (line 14) | def __lt__(self, other: Any) -> bool:
    method __le__ (line 19) | def __le__(self, other: Any) -> bool:
    method __gt__ (line 24) | def __gt__(self, other: Any) -> bool:
    method __ge__ (line 29) | def __ge__(self, other: Any) -> bool:
    method __eq__ (line 34) | def __eq__(self, other: object) -> bool:
    method __hash__ (line 39) | def __hash__(self) -> int:

FILE: src/dotbot/messenger/messenger.py
  class Messenger (line 6) | class Messenger(metaclass=Singleton):
    method __init__ (line 7) | def __init__(self, level: Level = Level.ACTION):
    method set_level (line 11) | def set_level(self, level: Level) -> None:
    method use_color (line 14) | def use_color(self, yesno: bool) -> None:  # noqa: FBT001
    method log (line 17) | def log(self, level: Level, message: str) -> None:
    method debug (line 21) | def debug(self, message: str) -> None:
    method action (line 24) | def action(self, message: str) -> None:
    method info (line 27) | def info(self, message: str) -> None:
    method lowinfo (line 30) | def lowinfo(self, message: str) -> None:
    method warning (line 34) | def warning(self, message: str) -> None:
    method error (line 37) | def error(self, message: str) -> None:
    method _color (line 40) | def _color(self, level: Level) -> str:
    method _reset (line 56) | def _reset(self) -> str:

FILE: src/dotbot/plugin.py
  class Plugin (line 7) | class Plugin:
    method __init__ (line 16) | def __init__(self, context: Context):
    method can_handle (line 20) | def can_handle(self, directive: str) -> bool:
    method handle (line 26) | def handle(self, directive: str, data: Any) -> bool:

FILE: src/dotbot/plugins/clean.py
  class Clean (line 9) | class Clean(Plugin):
    method can_handle (line 18) | def can_handle(self, directive: str) -> bool:
    method handle (line 21) | def handle(self, directive: str, data: Any) -> bool:
    method _process_clean (line 27) | def _process_clean(self, targets: Any) -> bool:
    method _clean (line 43) | def _clean(self, target: str, *, force: bool, recursive: bool) -> bool:
    method _in_directory (line 72) | def _in_directory(self, path: str, directory: str) -> bool:

FILE: src/dotbot/plugins/create.py
  class Create (line 8) | class Create(Plugin):
    method can_handle (line 17) | def can_handle(self, directive: str) -> bool:
    method handle (line 20) | def handle(self, directive: str, data: Any) -> bool:
    method _process_paths (line 26) | def _process_paths(self, paths: Any) -> bool:
    method _exists (line 43) | def _exists(self, path: str) -> bool:
    method _create (line 50) | def _create(self, path: str, mode: int) -> bool:

FILE: src/dotbot/plugins/link.py
  class Link (line 13) | class Link(Plugin):
    method can_handle (line 22) | def can_handle(self, directive: str) -> bool:
    method handle (line 25) | def handle(self, directive: str, data: Any) -> bool:
    method _process_links (line 31) | def _process_links(self, links: Any) -> bool:
    method _test_success (line 157) | def _test_success(self, command: str) -> bool:
    method _default_target (line 163) | def _default_target(self, link_name: str, target: Optional[str]) -> str:
    method _has_glob_chars (line 171) | def _has_glob_chars(self, path: str) -> bool:
    method _glob (line 174) | def _glob(self, path: str) -> List[str]:
    method _create_glob_results (line 188) | def _create_glob_results(self, path: str, exclude_paths: List[str]) ->...
    method _is_link (line 201) | def _is_link(self, path: str) -> bool:
    method _link_target (line 207) | def _link_target(self, path: str) -> str:
    method _exists (line 217) | def _exists(self, path: str) -> bool:
    method _lexists (line 224) | def _lexists(self, path: str) -> bool:
    method _create (line 231) | def _create(self, path: str) -> bool:
    method _backup (line 249) | def _backup(self, path: str) -> Tuple[bool, bool]:
    method _delete (line 268) | def _delete(
    method _relative_path (line 310) | def _relative_path(self, target: str, link_name: str) -> str:
    method _link (line 318) | def _link(

FILE: src/dotbot/plugins/shell.py
  class Shell (line 7) | class Shell(Plugin):
    method can_handle (line 17) | def can_handle(self, directive: str) -> bool:
    method handle (line 20) | def handle(self, directive: str, data: Any) -> bool:
    method _process_commands (line 26) | def _process_commands(self, data: Any) -> bool:
    method _get_option_overrides (line 77) | def _get_option_overrides(self) -> Dict[str, bool]:

FILE: src/dotbot/util/common.py
  function shell_command (line 8) | def shell_command(
  function normslash (line 46) | def normslash(path: str) -> str:

FILE: src/dotbot/util/module.py
  function load (line 13) | def load(path: str) -> List[Type[Plugin]]:
  function load_module (line 29) | def load_module(module_name: str, path: str) -> ModuleType:
  function load_plugins (line 39) | def load_plugins(paths: List[str], plugins: Optional[List[Type[Plugin]]]...

FILE: src/dotbot/util/singleton.py
  class Singleton (line 4) | class Singleton(type):
    method __call__ (line 5) | def __call__(cls, *args: Any, **kwargs: Any) -> Any:
    method reset_instance (line 10) | def reset_instance(cls) -> None:

FILE: src/dotbot/util/string.py
  function indent_lines (line 1) | def indent_lines(string: str, amount: int = 2, delimiter: str = "\n") ->...

FILE: tests/conftest.py
  function get_long_path (line 18) | def get_long_path(path: str) -> str:
  function get_path_from_fd (line 38) | def get_path_from_fd(fd: int) -> Optional[str]:
  function wrap_function (line 60) | def wrap_function(
  function wrap_open (line 100) | def wrap_open(root: str) -> Callable[..., Any]:
  function rmtree_error_handler (line 125) | def rmtree_error_handler(_function: Any, path: str, _excinfo: Any) -> None:
  function standardize_tmp (line 135) | def standardize_tmp() -> None:
  function root (line 161) | def root(standardize_tmp: None) -> Generator[str, None, None]:
  function home (line 266) | def home(monkeypatch: pytest.MonkeyPatch, root: str) -> str:
  class Dotfiles (line 282) | class Dotfiles:
    method __init__ (line 285) | def __init__(self, root: str):
    method makedirs (line 292) | def makedirs(self, path: str) -> None:
    method write (line 295) | def write(self, path: str, content: str = "") -> None:
    method write_config (line 302) | def write_config(self, config: Any, serializer: str = "yaml", path: Op...
    method config_filename (line 328) | def config_filename(self) -> str:
  function dotfiles (line 334) | def dotfiles(root: str) -> Dotfiles:
  function run_dotbot (line 341) | def run_dotbot(dotfiles: Dotfiles) -> Callable[..., None]:

FILE: tests/dotbot_plugin_context_plugin.py
  class Dispatch (line 10) | class Dispatch(dotbot.Plugin):
    method can_handle (line 11) | def can_handle(self, directive: str) -> bool:
    method handle (line 14) | def handle(self, directive: str, data: Any) -> bool:

FILE: tests/dotbot_plugin_counter.py
  class Counter (line 13) | class Counter(dotbot.Plugin):
    method can_handle (line 14) | def can_handle(self, directive: str) -> bool:
    method handle (line 17) | def handle(self, directive: str, _data: Any) -> bool:

FILE: tests/dotbot_plugin_directory.py
  class Directory (line 13) | class Directory(dotbot.Plugin):
    method can_handle (line 14) | def can_handle(self, directive: str) -> bool:
    method handle (line 17) | def handle(self, directive: str, data: Any) -> bool:

FILE: tests/dotbot_plugin_dispatcher_no_plugins.py
  class Dispatch (line 11) | class Dispatch(dotbot.Plugin):
    method can_handle (line 12) | def can_handle(self, directive: str) -> bool:
    method handle (line 15) | def handle(self, directive: str, data: Any) -> bool:

FILE: tests/dotbot_plugin_dry_run.py
  class DryRun (line 7) | class DryRun(dotbot.Plugin):
    method can_handle (line 13) | def can_handle(self, directive: str) -> bool:
    method handle (line 16) | def handle(self, _directive: str, _data: Any) -> bool:

FILE: tests/dotbot_plugin_file.py
  class File (line 13) | class File(dotbot.Plugin):
    method can_handle (line 14) | def can_handle(self, directive: str) -> bool:
    method handle (line 17) | def handle(self, directive: str, data: Any) -> bool:

FILE: tests/dotbot_plugin_issue_357.py
  class NoopPlugin (line 14) | class NoopPlugin(Plugin):
    method can_handle (line 17) | def can_handle(self, directive: str) -> bool:
    method handle (line 20) | def handle(self, directive: str, _data: Any) -> bool:

FILE: tests/test_bin_dotbot.py
  function test_find_python_executable (line 16) | def test_find_python_executable(python_name: Optional[str], home: str, d...

FILE: tests/test_clean.py
  function test_clean_default (line 10) | def test_clean_default(root: str, home: str, dotfiles: Dotfiles, run_dot...
  function test_clean_environment_variable_expansion (line 30) | def test_clean_environment_variable_expansion(home: str, dotfiles: Dotfi...
  function test_clean_missing (line 43) | def test_clean_missing(home: str, dotfiles: Dotfiles, run_dotbot: Callab...
  function test_clean_nonexistent (line 56) | def test_clean_nonexistent(home: str, dotfiles: Dotfiles, run_dotbot: Ca...
  function test_clean_outside_force (line 65) | def test_clean_outside_force(root: str, home: str, dotfiles: Dotfiles, r...
  function test_clean_outside (line 75) | def test_clean_outside(home: str, dotfiles: Dotfiles, run_dotbot: Callab...
  function test_clean_recursive_1 (line 87) | def test_clean_recursive_1(root: str, home: str, dotfiles: Dotfiles, run...
  function test_clean_recursive_2 (line 102) | def test_clean_recursive_2(root: str, home: str, dotfiles: Dotfiles, run...
  function test_clean_defaults_1 (line 117) | def test_clean_defaults_1(root: str, home: str, dotfiles: Dotfiles, run_...
  function test_clean_defaults_2 (line 127) | def test_clean_defaults_2(root: str, home: str, dotfiles: Dotfiles, run_...
  function test_clean_dry_run (line 142) | def test_clean_dry_run(
  function test_clean_dry_run_recursive (line 160) | def test_clean_dry_run_recursive(

FILE: tests/test_cli.py
  function test_except_create (line 10) | def test_except_create(
  function test_except_shell (line 32) | def test_except_shell(
  function test_except_multiples (line 54) | def test_except_multiples(
  function test_exit_on_failure (line 76) | def test_exit_on_failure(home: str, dotfiles: Dotfiles, run_dotbot: Call...
  function test_only (line 93) | def test_only(
  function test_only_with_defaults (line 111) | def test_only_with_defaults(
  function test_only_with_multiples (line 130) | def test_only_with_multiples(
  function test_plugin_loading_file (line 150) | def test_plugin_loading_file(home: str, dotfiles: Dotfiles, run_dotbot: ...
  function test_plugin_loading_directory (line 162) | def test_plugin_loading_directory(home: str, dotfiles: Dotfiles, run_dot...
  function test_issue_357 (line 175) | def test_issue_357(
  function test_disable_builtin_plugins (line 190) | def test_disable_builtin_plugins(home: str, dotfiles: Dotfiles, run_dotb...
  function test_plugin_context_plugin (line 203) | def test_plugin_context_plugin(
  function test_plugin_dispatcher_no_plugins (line 218) | def test_plugin_dispatcher_no_plugins(
  function test_dry_run_unaware_plugin (line 233) | def test_dry_run_unaware_plugin(
  function test_dry_run_aware_plugin (line 249) | def test_dry_run_aware_plugin(
  function test_dry_run_aware_plugin_no_dry_run (line 264) | def test_dry_run_aware_plugin_no_dry_run(home: str, dotfiles: Dotfiles, ...

FILE: tests/test_config.py
  function test_config_blank (line 8) | def test_config_blank(dotfiles: Dotfiles, run_dotbot: Callable[..., None...
  function test_config_empty (line 15) | def test_config_empty(dotfiles: Dotfiles, run_dotbot: Callable[..., None...
  function test_json (line 22) | def test_json(home: str, dotfiles: Dotfiles, run_dotbot: Callable[..., N...
  function test_json_tabs (line 32) | def test_json_tabs(home: str, dotfiles: Dotfiles, run_dotbot: Callable[....
  function test_multiple_config (line 42) | def test_multiple_config(home: str, dotfiles: Dotfiles, run_dotbot: Call...

FILE: tests/test_create.py
  function test_directory_creation (line 11) | def test_directory_creation(home: str, directory: str, dotfiles: Dotfile...
  function test_default_mode (line 23) | def test_default_mode(home: str, dotfiles: Dotfiles, run_dotbot: Callabl...
  function test_default_mode_override (line 42) | def test_default_mode_override(home: str, dotfiles: Dotfiles, run_dotbot...
  function test_create_dry_run (line 64) | def test_create_dry_run(

FILE: tests/test_link.py
  function test_link_canonicalization (line 15) | def test_link_canonicalization(home: str, dotfiles: Dotfiles, run_dotbot...
  function test_link_default_target (line 40) | def test_link_default_target(
  function test_link_environment_user_expansion_link_name (line 69) | def test_link_environment_user_expansion_link_name(
  function test_link_environment_variable_expansion_target (line 86) | def test_link_environment_variable_expansion_target(
  function test_link_environment_variable_expansion_target_extended (line 103) | def test_link_environment_variable_expansion_target_extended(
  function test_link_environment_variable_expansion_link_name (line 120) | def test_link_environment_variable_expansion_link_name(
  function test_link_environment_variable_unset (line 155) | def test_link_environment_variable_unset(
  function test_link_force_leaves_when_nonexistent (line 169) | def test_link_force_leaves_when_nonexistent(home: str, dotfiles: Dotfile...
  function test_link_force_overwrite_symlink (line 190) | def test_link_force_overwrite_symlink(home: str, dotfiles: Dotfiles, run...
  function test_link_force_directory (line 204) | def test_link_force_directory(home: str, dotfiles: Dotfiles, run_dotbot:...
  function test_link_backup_directory (line 220) | def test_link_backup_directory(home: str, dotfiles: Dotfiles, run_dotbot...
  function test_link_backup_file (line 240) | def test_link_backup_file(home: str, dotfiles: Dotfiles, run_dotbot: Cal...
  function test_link_backup_not_created_if_link (line 259) | def test_link_backup_not_created_if_link(home: str, dotfiles: Dotfiles, ...
  function test_link_backup_created_if_force (line 274) | def test_link_backup_created_if_force(home: str, dotfiles: Dotfiles, run...
  function test_link_backup_error_if_dest_already_exists (line 293) | def test_link_backup_error_if_dest_already_exists(
  function test_link_backup_glob (line 319) | def test_link_backup_glob(home: str, dotfiles: Dotfiles, run_dotbot: Cal...
  function test_link_backup_dry_run (line 365) | def test_link_backup_dry_run(home: str, dotfiles: Dotfiles, run_dotbot: ...
  function test_link_backup_relink_force_with_existing_incorrect_symlink (line 381) | def test_link_backup_relink_force_with_existing_incorrect_symlink(
  function test_link_backup_relink_real_file_skips_delete (line 411) | def test_link_backup_relink_real_file_skips_delete(
  function test_link_backup_relink_with_existing_incorrect_symlink_glob (line 450) | def test_link_backup_relink_with_existing_incorrect_symlink_glob(
  function test_link_glob_1 (line 480) | def test_link_glob_1(home: str, dotfiles: Dotfiles, run_dotbot: Callable...
  function test_link_glob_2 (line 502) | def test_link_glob_2(home: str, dotfiles: Dotfiles, run_dotbot: Callable...
  function test_link_glob_3 (line 524) | def test_link_glob_3(home: str, dotfiles: Dotfiles, run_dotbot: Callable...
  function test_link_glob_4 (line 546) | def test_link_glob_4(home: str, dotfiles: Dotfiles, run_dotbot: Callable...
  function test_link_glob_force (line 574) | def test_link_glob_force(home: str, dotfiles: Dotfiles, run_dotbot: Call...
  function test_link_glob_ignore_no_glob_chars (line 605) | def test_link_glob_ignore_no_glob_chars(
  function test_link_glob_exclude_1 (line 628) | def test_link_glob_exclude_1(home: str, dotfiles: Dotfiles, run_dotbot: ...
  function test_link_glob_exclude_2 (line 670) | def test_link_glob_exclude_2(home: str, dotfiles: Dotfiles, run_dotbot: ...
  function test_link_glob_exclude_3 (line 714) | def test_link_glob_exclude_3(home: str, dotfiles: Dotfiles, run_dotbot: ...
  function test_link_glob_exclude_4 (line 765) | def test_link_glob_exclude_4(home: str, dotfiles: Dotfiles, run_dotbot: ...
  function test_link_glob_multi_star (line 812) | def test_link_glob_multi_star(home: str, dotfiles: Dotfiles, run_dotbot:...
  function test_link_glob_patterns (line 848) | def test_link_glob_patterns(
  function test_link_glob_recursive (line 882) | def test_link_glob_recursive(home: str, dotfiles: Dotfiles, run_dotbot: ...
  function test_link_glob_no_match (line 908) | def test_link_glob_no_match(home: str, dotfiles: Dotfiles, run_dotbot: C...
  function test_link_glob_single_match (line 922) | def test_link_glob_single_match(home: str, dotfiles: Dotfiles, run_dotbo...
  function test_link_if (line 946) | def test_link_if(home: str, dotfiles: Dotfiles, run_dotbot: Callable[......
  function test_link_if_defaults (line 977) | def test_link_if_defaults(home: str, dotfiles: Dotfiles, run_dotbot: Cal...
  function test_link_if_windows (line 1010) | def test_link_if_windows(home: str, dotfiles: Dotfiles, run_dotbot: Call...
  function test_link_if_defaults_windows (line 1041) | def test_link_if_defaults_windows(home: str, dotfiles: Dotfiles, run_dot...
  function test_link_ignore_missing (line 1071) | def test_link_ignore_missing(
  function test_link_leaves_file (line 1100) | def test_link_leaves_file(home: str, dotfiles: Dotfiles, run_dotbot: Cal...
  function test_link_no_canonicalize (line 1115) | def test_link_no_canonicalize(key: str, home: str, dotfiles: Dotfiles, r...
  function test_link_prefix (line 1133) | def test_link_prefix(home: str, dotfiles: Dotfiles, run_dotbot: Callable...
  function test_link_relative (line 1161) | def test_link_relative(home: str, dotfiles: Dotfiles, run_dotbot: Callab...
  function test_link_relink_leaves_file (line 1222) | def test_link_relink_leaves_file(home: str, dotfiles: Dotfiles, run_dotb...
  function test_link_relink_overwrite_symlink (line 1235) | def test_link_relink_overwrite_symlink(home: str, dotfiles: Dotfiles, ru...
  function test_link_relink_relative_leaves_file (line 1248) | def test_link_relink_relative_leaves_file(home: str, dotfiles: Dotfiles,...
  function test_target_is_not_overwritten_by_symlink_trickery (line 1278) | def test_target_is_not_overwritten_by_symlink_trickery(
  function test_link_defaults_1 (line 1322) | def test_link_defaults_1(home: str, dotfiles: Dotfiles, run_dotbot: Call...
  function test_link_defaults_2 (line 1343) | def test_link_defaults_2(home: str, dotfiles: Dotfiles, run_dotbot: Call...
  function test_link_type_symlink (line 1379) | def test_link_type_symlink(
  function test_link_type_hardlink (line 1407) | def test_link_type_hardlink(
  function test_unknown_link_type (line 1435) | def test_unknown_link_type(
  function test_symlink_exists_when_hardlink_requested (line 1450) | def test_symlink_exists_when_hardlink_requested(
  function test_hardlink_already_exists (line 1475) | def test_hardlink_already_exists(
  function test_broken_symlink_shows_invalid_link_message (line 1498) | def test_broken_symlink_shows_invalid_link_message(
  function test_link_dry_run (line 1521) | def test_link_dry_run(
  function test_link_dry_run_if (line 1552) | def test_link_dry_run_if(
  function test_link_dry_run_create (line 1588) | def test_link_dry_run_create(
  function test_link_dry_run_relink (line 1619) | def test_link_dry_run_relink(
  function test_link_dry_run_overwrite (line 1651) | def test_link_dry_run_overwrite(
  function test_link_error_creating_link (line 1691) | def test_link_error_creating_link(
  function test_link_error_creating_directory (line 1725) | def test_link_error_creating_directory(
  function test_link_error_delete (line 1759) | def test_link_error_delete(

FILE: tests/test_noop.py
  function test_success (line 6) | def test_success(root: str) -> None:
  function test_failure (line 14) | def test_failure() -> None:

FILE: tests/test_plugins.py
  function test_plugin_file (line 10) | def test_plugin_file(home: str, dotfiles: Dotfiles, run_dotbot: Callable...
  function test_plugin_absolute_path (line 26) | def test_plugin_absolute_path(home: str, dotfiles: Dotfiles, run_dotbot:...
  function test_plugin_directory (line 41) | def test_plugin_directory(home: str, dotfiles: Dotfiles, run_dotbot: Cal...
  function test_plugin_multiple (line 57) | def test_plugin_multiple(home: str, dotfiles: Dotfiles, run_dotbot: Call...
  function test_plugin_command_line_and_config (line 78) | def test_plugin_command_line_and_config(home: str, dotfiles: Dotfiles, r...
  function test_plugin_nonexistent (line 99) | def test_plugin_nonexistent(
  function test_plugin_empty_list (line 117) | def test_plugin_empty_list(home: str, dotfiles: Dotfiles, run_dotbot: Ca...
  function test_plugin_multiple_directives (line 131) | def test_plugin_multiple_directives(home: str, dotfiles: Dotfiles, run_d...
  function test_plugin_duplicate_loading (line 153) | def test_plugin_duplicate_loading(home: str, dotfiles: Dotfiles, run_dot...
  function test_plugin_subdirectory (line 170) | def test_plugin_subdirectory(home: str, dotfiles: Dotfiles, run_dotbot: ...

FILE: tests/test_shell.py
  function test_shell_allow_stdout (line 8) | def test_shell_allow_stdout(
  function test_shell_cli_verbosity_overrides_1 (line 31) | def test_shell_cli_verbosity_overrides_1(
  function test_shell_cli_verbosity_overrides_2 (line 43) | def test_shell_cli_verbosity_overrides_2(
  function test_shell_cli_verbosity_overrides_3 (line 55) | def test_shell_cli_verbosity_overrides_3(
  function test_shell_cli_verbosity_stderr (line 72) | def test_shell_cli_verbosity_stderr(
  function test_shell_cli_verbosity_stderr_with_explicit_stdout_off (line 84) | def test_shell_cli_verbosity_stderr_with_explicit_stdout_off(
  function test_shell_cli_verbosity_stderr_with_defaults_stdout_off (line 107) | def test_shell_cli_verbosity_stderr_with_defaults_stdout_off(
  function test_shell_single_v_verbosity_stdout (line 134) | def test_shell_single_v_verbosity_stdout(
  function test_shell_single_v_verbosity_stderr (line 146) | def test_shell_single_v_verbosity_stderr(
  function test_shell_compact_stdout_1 (line 158) | def test_shell_compact_stdout_1(
  function test_shell_compact_stdout_2 (line 175) | def test_shell_compact_stdout_2(
  function test_shell_stdout_disabled_by_default (line 193) | def test_shell_stdout_disabled_by_default(
  function test_shell_can_override_defaults (line 211) | def test_shell_can_override_defaults(
  function test_shell_quiet_default (line 228) | def test_shell_quiet_default(
  function test_shell_quiet_enabled_with_description (line 253) | def test_shell_quiet_enabled_with_description(
  function test_shell_quiet_enabled_without_description (line 279) | def test_shell_quiet_enabled_without_description(
  function test_shell_dry_run (line 303) | def test_shell_dry_run(capfd: pytest.CaptureFixture[str], dotfiles: Dotf...

FILE: tests/test_shim.py
  function test_shim (line 11) | def test_shim(home: str, dotfiles: Dotfiles) -> None:
Condensed preview — 59 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (229K chars).
[
  {
    "path": ".ci/check-changelog.py",
    "chars": 2089,
    "preview": "# noqa: INP001\n\n\nimport argparse\nimport re\nimport subprocess\nimport sys\nfrom typing import Set, Tuple\n\n\ndef main() -> No"
  },
  {
    "path": ".editorconfig",
    "chars": 155,
    "preview": "root = true\n\n[*]\ncharset = utf-8\nend_of_line = lf\ninsert_final_newline = true\nindent_style = space\ntrim_trailing_whitesp"
  },
  {
    "path": ".github/dependabot.yaml",
    "chars": 187,
    "preview": "version: 2\nupdates:\n  - package-ecosystem: \"github-actions\"\n    directory: \"/\"\n    schedule:\n      interval: \"monthly\"\n "
  },
  {
    "path": ".github/workflows/ci.yml",
    "chars": 1904,
    "preview": "name: CI\non:\n  push:\n  pull_request:\n  schedule:\n    - cron: \"0 8 * * 6\"\njobs:\n  test:\n    env:\n      PIP_DISABLE_PIP_VE"
  },
  {
    "path": ".github/workflows/release.yml",
    "chars": 3112,
    "preview": "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_VERSI"
  },
  {
    "path": ".gitignore",
    "chars": 23,
    "preview": "*.pyc\ndist/\n.coverage*\n"
  },
  {
    "path": ".gitmodules",
    "chars": 98,
    "preview": "[submodule \"lib/pyyaml\"]\n\tpath = lib/pyyaml\n\turl = https://github.com/yaml/pyyaml\n\tignore = dirty\n"
  },
  {
    "path": "CHANGELOG.md",
    "chars": 1938,
    "preview": "Note: this changelog only lists feature additions, not bugfixes. For details on those, see the Git history.\n\n- v1.24\n   "
  },
  {
    "path": "CONTRIBUTING.md",
    "chars": 2677,
    "preview": "# Contributing\n\nAll kinds of contributions to Dotbot are greatly appreciated. For someone unfamiliar with the code base,"
  },
  {
    "path": "DEVELOPMENT.md",
    "chars": 2574,
    "preview": "# Development\n\nDotbot uses the [Hatch] project manager ([installation instructions][hatch-install]).\n\nHatch automaticall"
  },
  {
    "path": "LICENSE.md",
    "chars": 1123,
    "preview": "The MIT License (MIT)\n=====================\n\n**Copyright (c) Anish Athalye (me@anishathalye.com)**\n\nPermission is hereby"
  },
  {
    "path": "README.md",
    "chars": 20814,
    "preview": "# Dotbot [![Build Status](https://github.com/anishathalye/dotbot/actions/workflows/ci.yml/badge.svg)](https://github.com"
  },
  {
    "path": "bin/dotbot",
    "chars": 1593,
    "preview": "#!/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 shel"
  },
  {
    "path": "codecov.yml",
    "chars": 52,
    "preview": "coverage:\n  status:\n    project: off\n    patch: off\n"
  },
  {
    "path": "pyproject.toml",
    "chars": 2203,
    "preview": "[build-system]\nrequires = [\"hatchling\"]\nbuild-backend = \"hatchling.build\"\n\n[project]\nname = \"dotbot\"\nauthors = [\n  { nam"
  },
  {
    "path": "src/dotbot/__about__.py",
    "chars": 23,
    "preview": "__version__ = \"1.24.1\"\n"
  },
  {
    "path": "src/dotbot/__init__.py",
    "chars": 147,
    "preview": "from dotbot.__about__ import __version__\nfrom dotbot.cli import main\nfrom dotbot.plugin import Plugin\n\n__all__ = [\"Plugi"
  },
  {
    "path": "src/dotbot/cli.py",
    "chars": 5655,
    "preview": "import os\nimport subprocess\nimport sys\nfrom argparse import SUPPRESS, ArgumentParser, RawTextHelpFormatter\nfrom typing i"
  },
  {
    "path": "src/dotbot/config.py",
    "chars": 1100,
    "preview": "import json\nimport os.path\nfrom typing import Any, List\n\nimport yaml\n\nfrom dotbot.util import string\n\n\nclass ConfigReade"
  },
  {
    "path": "src/dotbot/context.py",
    "chars": 1468,
    "preview": "import copy\nimport os\nfrom argparse import Namespace\nfrom typing import TYPE_CHECKING, Any, Dict, List, Optional, Type\n\n"
  },
  {
    "path": "src/dotbot/dispatcher.py",
    "chars": 5443,
    "preview": "import os\nfrom argparse import Namespace\nfrom typing import Any, Dict, List, Optional, Type\n\nfrom dotbot.context import "
  },
  {
    "path": "src/dotbot/messenger/__init__.py",
    "chars": 124,
    "preview": "from dotbot.messenger.level import Level\nfrom dotbot.messenger.messenger import Messenger\n\n__all__ = [\"Level\", \"Messenge"
  },
  {
    "path": "src/dotbot/messenger/color.py",
    "chars": 164,
    "preview": "class Color:\n    NONE = \"\"\n    RESET = \"\\033[0m\"\n    RED = \"\\033[91m\"\n    GREEN = \"\\033[92m\"\n    YELLOW = \"\\033[93m\"\n   "
  },
  {
    "path": "src/dotbot/messenger/level.py",
    "chars": 1083,
    "preview": "from enum import Enum\nfrom typing import Any\n\n\nclass Level(Enum):\n    NOTSET = 0\n    DEBUG = 10\n    INFO = 15\n    LOWINF"
  },
  {
    "path": "src/dotbot/messenger/messenger.py",
    "chars": 1853,
    "preview": "from dotbot.messenger.color import Color\nfrom dotbot.messenger.level import Level\nfrom dotbot.util.singleton import Sing"
  },
  {
    "path": "src/dotbot/plugin.py",
    "chars": 827,
    "preview": "from typing import Any\n\nfrom dotbot.context import Context\nfrom dotbot.messenger import Messenger\n\n\nclass Plugin:\n    \"\""
  },
  {
    "path": "src/dotbot/plugins/__init__.py",
    "chars": 204,
    "preview": "from dotbot.plugins.clean import Clean\nfrom dotbot.plugins.create import Create\nfrom dotbot.plugins.link import Link\nfro"
  },
  {
    "path": "src/dotbot/plugins/clean.py",
    "chars": 3368,
    "preview": "import os\nimport sys\nfrom typing import Any\n\nfrom dotbot.plugin import Plugin\nfrom dotbot.util.common import normslash\n\n"
  },
  {
    "path": "src/dotbot/plugins/create.py",
    "chars": 2325,
    "preview": "import os\nfrom typing import Any\n\nfrom dotbot.plugin import Plugin\nfrom dotbot.util.common import normslash\n\n\nclass Crea"
  },
  {
    "path": "src/dotbot/plugins/link.py",
    "chars": 17234,
    "preview": "import glob\nimport os\nimport shutil\nimport sys\nfrom datetime import datetime, timezone\nfrom typing import Any, List, Opt"
  },
  {
    "path": "src/dotbot/plugins/shell.py",
    "chars": 3096,
    "preview": "from typing import Any, Dict\n\nfrom dotbot.plugin import Plugin\nfrom dotbot.util.common import shell_command\n\n\nclass Shel"
  },
  {
    "path": "src/dotbot/util/__init__.py",
    "chars": 74,
    "preview": "from dotbot.util.common import shell_command\n\n__all__ = [\"shell_command\"]\n"
  },
  {
    "path": "src/dotbot/util/common.py",
    "chars": 1817,
    "preview": "import os\nimport platform\nimport subprocess\nimport sys\nfrom typing import Optional\n\n\ndef shell_command(\n    command: str"
  },
  {
    "path": "src/dotbot/util/module.py",
    "chars": 2568,
    "preview": "import glob\nimport importlib.util\nimport os\nfrom types import ModuleType\nfrom typing import List, Optional, Type\n\nfrom d"
  },
  {
    "path": "src/dotbot/util/singleton.py",
    "chars": 394,
    "preview": "from typing import Any\n\n\nclass Singleton(type):\n    def __call__(cls, *args: Any, **kwargs: Any) -> Any:\n        if not "
  },
  {
    "path": "src/dotbot/util/string.py",
    "chars": 207,
    "preview": "def indent_lines(string: str, amount: int = 2, delimiter: str = \"\\n\") -> str:\n    whitespace = \" \" * amount\n    sep = f\""
  },
  {
    "path": "tests/__init__.py",
    "chars": 0,
    "preview": ""
  },
  {
    "path": "tests/conftest.py",
    "chars": 12557,
    "preview": "import builtins\nimport ctypes\nimport json\nimport os\nimport shutil\nimport sys\nimport tempfile\nfrom shutil import rmtree\nf"
  },
  {
    "path": "tests/dotbot_plugin_context_plugin.py",
    "chars": 755,
    "preview": "# https://github.com/anishathalye/dotbot/issues/339\n# plugins should be able to instantiate a Dispatcher with all the pl"
  },
  {
    "path": "tests/dotbot_plugin_counter.py",
    "chars": 901,
    "preview": "\"\"\"Test plugin that counts how many times it's executed.\n\nThis file is used to test that duplicate plugin references\ndon"
  },
  {
    "path": "tests/dotbot_plugin_directory.py",
    "chars": 1073,
    "preview": "\"\"\"Test that a plugin can be loaded by directory.\n\nThis file is copied to a location with the name \"directory.py\",\nand i"
  },
  {
    "path": "tests/dotbot_plugin_dispatcher_no_plugins.py",
    "chars": 842,
    "preview": "# https://github.com/anishathalye/dotbot/issues/339, https://github.com/anishathalye/dotbot/pull/332\n# if plugins instan"
  },
  {
    "path": "tests/dotbot_plugin_dry_run.py",
    "chars": 602,
    "preview": "import os\nfrom typing import Any\n\nimport dotbot\n\n\nclass DryRun(dotbot.Plugin):\n    \"\"\"A plugin that is aware of dry-run "
  },
  {
    "path": "tests/dotbot_plugin_file.py",
    "chars": 1227,
    "preview": "\"\"\"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"
  },
  {
    "path": "tests/dotbot_plugin_issue_357.py",
    "chars": 663,
    "preview": "from typing import Any\n\nfrom dotbot.plugin import Plugin\nfrom dotbot.plugins import Clean, Create, Link, Shell\n\n# https:"
  },
  {
    "path": "tests/test_bin_dotbot.py",
    "chars": 1537,
    "preview": "import os\nimport shutil\nimport subprocess\nfrom typing import Optional\n\nimport pytest\n\nfrom tests.conftest import Dotfile"
  },
  {
    "path": "tests/test_clean.py",
    "chars": 7178,
    "preview": "import os\nimport sys\nfrom typing import Callable\n\nimport pytest\n\nfrom tests.conftest import Dotfiles\n\n\ndef test_clean_de"
  },
  {
    "path": "tests/test_cli.py",
    "chars": 10115,
    "preview": "import os\nimport shutil\nfrom typing import Callable\n\nimport pytest\n\nfrom tests.conftest import Dotfiles\n\n\ndef test_excep"
  },
  {
    "path": "tests/test_config.py",
    "chars": 1808,
    "preview": "import json\nimport os\nfrom typing import Callable\n\nfrom tests.conftest import Dotfiles\n\n\ndef test_config_blank(dotfiles:"
  },
  {
    "path": "tests/test_create.py",
    "chars": 2936,
    "preview": "import os\nimport stat\nfrom typing import Callable\n\nimport pytest\n\nfrom tests.conftest import Dotfiles\n\n\n@pytest.mark.par"
  },
  {
    "path": "tests/test_link.py",
    "chars": 59969,
    "preview": "import os\nimport pathlib\nimport stat\nimport sys\nfrom datetime import datetime, timedelta, timezone\nfrom typing import An"
  },
  {
    "path": "tests/test_noop.py",
    "chars": 564,
    "preview": "import os\n\nimport pytest\n\n\ndef test_success(root: str) -> None:\n    path = os.path.join(root, \"abc.txt\")\n    with open(p"
  },
  {
    "path": "tests/test_plugins.py",
    "chars": 7901,
    "preview": "import os\nimport shutil\nfrom typing import Callable\n\nimport pytest\n\nfrom tests.conftest import Dotfiles\n\n\ndef test_plugi"
  },
  {
    "path": "tests/test_shell.py",
    "chars": 9497,
    "preview": "from typing import Callable\n\nimport pytest\n\nfrom tests.conftest import Dotfiles\n\n\ndef test_shell_allow_stdout(\n    capfd"
  },
  {
    "path": "tests/test_shim.py",
    "chars": 1645,
    "preview": "import os\nimport shutil\nimport subprocess\nimport sys\n\nimport pytest\n\nfrom tests.conftest import Dotfiles\n\n\ndef test_shim"
  },
  {
    "path": "tools/git-submodule/install",
    "chars": 368,
    "preview": "#!/usr/bin/env bash\n\nset -e\n\nCONFIG=\"install.conf.yaml\"\nDOTBOT_DIR=\"dotbot\"\n\nDOTBOT_BIN=\"bin/dotbot\"\nBASEDIR=\"$(cd \"$(di"
  },
  {
    "path": "tools/git-submodule/install.ps1",
    "chars": 736,
    "preview": "$ErrorActionPreference = \"Stop\"\n\n$CONFIG = \"install.conf.yaml\"\n$DOTBOT_DIR = \"dotbot\"\n\n$DOTBOT_BIN = \"bin/dotbot\"\n$BASED"
  },
  {
    "path": "tools/hg-subrepo/install",
    "chars": 318,
    "preview": "#!/usr/bin/env bash\n\nset -e\n\nCONFIG=\"install.conf.yaml\"\nDOTBOT_DIR=\"dotbot\"\n\nDOTBOT_BIN=\"bin/dotbot\"\nBASEDIR=\"$(cd \"$(di"
  },
  {
    "path": "tools/hg-subrepo/install.ps1",
    "chars": 698,
    "preview": "$ErrorActionPreference = \"Stop\"\n\n$CONFIG = \"install.conf.yaml\"\n$DOTBOT_DIR = \"dotbot\"\n\n$DOTBOT_BIN = \"bin/dotbot\"\n$BASED"
  }
]

About this extraction

This page contains the full source code of the anishathalye/dotbot GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 59 files (208.6 KB), approximately 52.9k tokens, and a symbol index with 264 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!