Skip to content

fix(operations.files): make files.link idempotent for existing hard links - #1962

Open
ethanstoner wants to merge 1 commit into
pyinfra-dev:3.xfrom
ethanstoner:fix/files-link-hardlink-idempotent
Open

ethanstoner wants to merge 1 commit into
pyinfra-dev:3.xfrom
ethanstoner:fix/files-link-hardlink-idempotent

Conversation

@ethanstoner

Copy link
Copy Markdown

Closes #945

Root cause

files.link looks up the Link fact, which returns False for any existing path that isn't a symlink. That False goes straight to _raise_or_remove_invalid_path before symbolic is ever checked. So for symbolic=False:

  • the first run creates the hard link
  • every run after that raises <path> exists and is not a link
  • with force=True, it moves the file away and recreates the link on every run

Fix

  • New fact files.SameFile(path, target): runs test PATH -ef TARGET, which is true when both paths share a device and inode.
  • In files.link: if symbolic=False and the path exists but isn't a symlink, check File + SameFile. If the path is already the target file, treat it as an existing link:
    • reruns are a no-op (link ... already exists)
    • user/group are still checked
    • present=False removes it
  • Otherwise nothing changes: an unrelated file still raises at prepare time, or is backed up/removed with force. The symlink path doesn't touch the new fact.

Design choice: a new fact, not a shell guard

I considered emitting a guarded command like [ path -ef target ] || ln -f target path, but decided against it:

  • It would silently overwrite an unrelated regular file, bypassing force/force_backup, or it would have to fail at execution time instead of at prepare time.
  • The operation would always emit a command, so reruns would report a change.

A fact matches how operations are meant to work (read state via facts, then yield only what's needed) and keeps the existing error/force semantics. test -ef works in bash, dash, busybox ash and the BSD/macOS sh/test.

Tests

  • Operation fixtures in tests/operations/files.link/:
    • edit_hard_link_nothing.json (no-op)
    • delete_hard_link.json (present=False)
    • invalid_hard_link.json (different file still errors)
  • Fact fixtures in tests/facts/files.SameFile/: same_file.json, different_file.json.
  • Before the fix, edit_hard_link_nothing and delete_hard_link failed and the fact fixtures errored. After the fix, all pass.

Local results:

  • uv run pytest --disable-warnings -m 'not end_to_end': 2064 passed
  • uv run ruff check, uv run ruff format --diff, uv run mypy, scripts/lint_arguments_sync.py, scripts/dev-shellcheck.sh: all clean

Real run of the deploy from the issue with pyinfra @local on macOS:

  • Before the fix: run 1 succeeded, run 2 failed with .../foo-hardlink exists and is not a link.
  • After the fix: run 1 Success, runs 2 and 3 No changes for all three operations. ls -li shows foo and foo-hardlink with the same inode and link count 2.
  • An unrelated regular file at the link path still fails with exists and is not a link, and the file is left untouched.

Checklist

  • Pull request is based on the default branch (3.x at this time)
  • Pull request includes tests for any new/updated operations/facts
  • Pull request includes documentation for any new/updated operations/facts (fact docstring; docs are generated from it)
  • Tests pass (see scripts/dev-test.sh)
  • Type checking & code style passes (see scripts/dev-lint.sh)
  • Pull request title follows the conventional commits format

AI disclosure

Per AI_POLICY.md: this PR was made with Claude Code. Claude Code wrote the code, the test fixtures and this description, and reproduced the bug and ran all the checks above locally. I reviewed the change before submitting.

…inks

files.link(..., symbolic=False) used the Link fact, which returns False for any path that is not a symlink, so an existing hard link raised "exists and is not a link" on every run after the first.

Add a files.SameFile fact (test PATH -ef TARGET) and, for hard links, treat a path that is already the same file as the target as an existing link.

Closes pyinfra-dev#945
@wowi42 wowi42 added bug Label for all kind of bugs. operations Issues with operations. facts Issues with facts. labels Sep 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Label for all kind of bugs. facts Issues with facts. operations Issues with operations.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

files.link operation fails for hard link

2 participants