# GitHub Issue #791: files.link did not remove old link pointing to different target

## Issue Summary

**Reported in:** pyinfra v2.0.1
**Current codebase:** v3.6
**Status:** Open (but appears FIXED in v3.x)
**URL:** https://github.com/pyinfra-dev/pyinfra/issues/791

When a symbolic link exists and points to a target with a trailing slash (e.g., `target/`), using `files.link` to update it to point to the same target without the trailing slash (e.g., `target`) does not detect this as a change and results in a noop.

### Reproduction Scenario

```python
from pyinfra.operations import files

# Existing link: /home/myuser/link -> target/
# First operation - creates/maintains link with trailing slash
files.link(path="/home/myuser/link", target="target/")

# Second operation - wants link without trailing slash
files.link(path="/home/myuser/link", target="target")
# Expected: link is updated to point to "target"
# Actual (v2.0.1): noop - "link already exists"
# Actual (v3.6): WORKS CORRECTLY - link is updated
```

### Expected Behavior
The link should be replaced if the old link target is not strictly identical to the new target, even if the difference is just a trailing slash.

### Actual Behavior (v2.0.1)
The second operation shows "noop: link already exists" even though the link targets are technically different (`target/` vs `target`).

### Current Behavior (v3.6) - BUG APPEARS FIXED
Testing with the current codebase shows the trailing slash difference IS correctly detected. A test case was added and passes:

```
tests/test_operations.py::files.link::test_files_link_edit_trailing_slash PASSED
```

The test confirms that when a link exists pointing to `target/` and the desired target is `target`, pyinfra correctly generates the `rm -f` and `ln -s` commands to update the link.

---

## Root Cause Analysis

After extensive investigation of the codebase, I identified **two distinct issues** that may contribute to this bug:

### Issue 1: Potential Operation Evaluation Interaction

During the prepare phase, pyinfra evaluates each operation's command generator to determine if changes will be made. When two operations target the same path:

1. First operation: Gathers Link fact, sees link doesn't exist (or has different target), plans to create/update
2. Second operation: Also gathers Link fact

If there's any state sharing or caching between these evaluations, the second operation might incorrectly see the "planned" state from the first operation rather than the actual current state.

**Location:** `/Users/nick/Dev/Webroot/pyinfra/src/src/pyinfra/api/operation.py`, lines 348-353

```python
op_is_change = None
if state.should_check_for_changes():
    op_is_change = False
    for _ in command_generator():
        op_is_change = True
        break
```

### Issue 2: Direct String Comparison Without Normalization

The comparison in the `link` operation uses direct string equality:

**Location:** `/Users/nick/Dev/Webroot/pyinfra/src/src/pyinfra/operations/files.py`, lines 1493-1497

```python
# If the target is wrong, remove & recreate the link
if not info or info["link_target"] != target:
    changed = True
    yield remove_cmd
    yield add_cmd
```

While this should correctly detect `"target/" != "target"`, there may be edge cases or normalization happening elsewhere.

### Secondary Issue: ln -s Behavior with Directory Symlinks

A related but separate issue was highlighted in the GitHub comments: when using `ln -sf` on a symlink that points to a directory, the command follows the symlink instead of replacing it. The fix suggested by users is to use `ln -nfs` instead.

However, this shouldn't affect pyinfra directly since pyinfra uses `rm -f` before `ln -s` when the target differs:

```python
remove_cmd = StringCommand("rm", "-f", QuoteString(path))
# ...
if not info or info["link_target"] != target:
    changed = True
    yield remove_cmd  # Remove old link first
    yield add_cmd     # Then create new link
```

---

## Proposed Fix

### Option A: Normalize Trailing Slashes (Recommended)

Add normalization to handle trailing slash differences in link targets. This ensures semantic equality even if the string representation differs.

**File:** `/Users/nick/Dev/Webroot/pyinfra/src/src/pyinfra/operations/files.py`

```python
@operation()
def link(
    path: str,
    target: str | None = None,
    present=True,
    user: str | None = None,
    group: str | None = None,
    symbolic=True,
    create_remote_dir=True,
    force=False,
    force_backup=True,
    force_backup_dir: str | None = None,
    normalize_target=False,  # New parameter
):
    # ... existing docstring ...

    path = _validate_path(path)

    if present and not target:
        raise OperationError("If present is True target must be provided")

    info = host.get_fact(Link, path=path)

    # ... existing code ...

    else:  # edit
        changed = False

        # Normalize targets for comparison if requested, or always for trailing slashes
        existing_target = info["link_target"] if info else None
        desired_target = target

        # Strip trailing slashes for comparison (they're semantically equivalent)
        if existing_target:
            existing_target_normalized = existing_target.rstrip("/")
        else:
            existing_target_normalized = None
        desired_target_normalized = target.rstrip("/") if target else None

        # If the target is wrong, remove & recreate the link
        if not info or existing_target_normalized != desired_target_normalized:
            changed = True
            yield remove_cmd
            yield add_cmd
```

**Trade-off:** This changes behavior - links with trailing slashes would be considered equivalent to those without. This might break existing deployments that rely on the exact target string.

### Option B: Strict Comparison (Keep Current Behavior, Fix Bug)

If the bug is related to operation evaluation caching/interaction, the fix should be in the operation evaluation logic, not the comparison logic. This requires deeper investigation into the operation prepare phase.

### Option C: Add Documentation Warning

If trailing slash semantics are intentionally preserved, add documentation warning users about this behavior:

```python
"""
Note:
    Trailing slashes in the target are significant. A link to ``target/`` is considered
    different from a link to ``target``. Ensure your target strings are consistent.
"""
```

---

## Related Files

| File | Purpose |
|------|---------|
| `/Users/nick/Dev/Webroot/pyinfra/src/src/pyinfra/operations/files.py` | Contains the `link` operation (lines 1401-1506) |
| `/Users/nick/Dev/Webroot/pyinfra/src/src/pyinfra/facts/files.py` | Contains the `Link` fact class (lines 290-309) |
| `/Users/nick/Dev/Webroot/pyinfra/src/src/pyinfra/api/operation.py` | Operation decorator and evaluation logic |
| `/Users/nick/Dev/Webroot/pyinfra/src/src/pyinfra/api/facts.py` | Fact gathering logic |

---

## Test Cases to Add

```python
# Test: Trailing slash detection
def test_link_trailing_slash_difference():
    """
    When a link exists pointing to 'target/' and we want 'target',
    the operation should detect the difference and update the link.
    """
    # Setup: Existing link with trailing slash
    facts = {
        "files.Link": {
            "path=/test/link": {
                "link_target": "target/",
                "user": "root",
                "group": "root"
            }
        }
    }

    # Operation: Request link without trailing slash
    result = files.link(path="/test/link", target="target")

    # Expected: Commands to remove and recreate the link
    assert "rm -f /test/link" in result.commands
    assert "ln -s target /test/link" in result.commands
```

---

## Verification Results

### Test Added
A test case was added to verify the trailing slash behavior:

**File:** `/Users/nick/Dev/Webroot/pyinfra/src/tests/operations/files.link/edit_trailing_slash.json`

```json
{
    "args": ["testlink"],
    "kwargs": {
        "target": "target"
    },
    "facts": {
        "files.Link": {
            "path=testlink": {
                "link_target": "target/",
                "user": "root",
                "group": "root"
            }
        }
    },
    "commands": [
        "rm -f testlink",
        "ln -s target testlink"
    ]
}
```

### Test Result
```
$ uv run pytest tests/test_operations.py -k "edit_trailing" -v
tests/test_operations.py::files.link::test_files_link_edit_trailing_slash PASSED
```

The test **PASSES**, confirming that in v3.6, pyinfra correctly detects the difference between `target/` and `target` and generates the appropriate commands to update the link.

---

## Recommendations

1. **Close the issue** - The bug appears to be fixed in v3.x
2. **Keep the test case** - The test `edit_trailing_slash.json` was added to prevent regression
3. **Document the behavior** - Consider adding documentation noting that trailing slashes in link targets are significant and will be preserved exactly as specified

---

## Possible Causes of Original Bug (v2.0.1)

The original bug in v2.0.1 may have been caused by:

1. **Fact caching between operations** - Facts may have been cached differently in v2.x, causing the second operation to see stale data
2. **Operation evaluation order** - The prepare phase logic may have had different behavior in v2.x
3. **Related fix for Issue #801** - The fix for parsing `link_target` in stat output (handling backticks) may have also addressed this issue

Since the issue was reported in April 2022 and v3.0 was released later, the refactoring between major versions likely resolved this bug.

---

## Related Issues

- Issue #801: Wrong link_target in Link Fact on RHEL 6 (related to stat output parsing) - CLOSED
