fix(operations.files): make files.link idempotent for existing hard links - #1962
Open
ethanstoner wants to merge 1 commit into
Open
ethanstoner wants to merge 1 commit into
ethanstoner wants to merge 1 commit into
Conversation
…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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #945
Root cause
files.linklooks up theLinkfact, which returnsFalsefor any existing path that isn't a symlink. ThatFalsegoes straight to_raise_or_remove_invalid_pathbeforesymbolicis ever checked. So forsymbolic=False:<path> exists and is not a linkforce=True, it moves the file away and recreates the link on every runFix
files.SameFile(path, target): runstest PATH -ef TARGET, which is true when both paths share a device and inode.files.link: ifsymbolic=Falseand the path exists but isn't a symlink, checkFile+SameFile. If the path is already the target file, treat it as an existing link:link ... already exists)present=Falseremoves itforce. 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:force/force_backup, or it would have to fail at execution time instead of at prepare time.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 -efworks in bash, dash, busybox ash and the BSD/macOSsh/test.Tests
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)tests/facts/files.SameFile/:same_file.json,different_file.json.edit_hard_link_nothinganddelete_hard_linkfailed and the fact fixtures errored. After the fix, all pass.Local results:
uv run pytest --disable-warnings -m 'not end_to_end': 2064 passeduv run ruff check,uv run ruff format --diff,uv run mypy,scripts/lint_arguments_sync.py,scripts/dev-shellcheck.sh: all cleanReal run of the deploy from the issue with
pyinfra @localon macOS:.../foo-hardlink exists and is not a link.Success, runs 2 and 3No changesfor all three operations.ls -lishowsfooandfoo-hardlinkwith the same inode and link count 2.exists and is not a link, and the file is left untouched.Checklist
3.xat this time)scripts/dev-test.sh)scripts/dev-lint.sh)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.