Repository: copier-org/copier (python)

Issue:
ProcessExecutionError when a file is un-ignored across template versions
### Describe the problem

In one tagged release of my copier template, I added an entry to a templated `.gitignore` to ignore the file `.env`. In a later tagged release, I removed this entry from the templated `.gitignore` file. Now, when upgrading templated projects, I get this error:

```text
ProcessExecutionError: | Unexpected exit code: 1
Stderr:                | The following paths are ignored by one of your .gitignore files:
                       | .env
```


### Template

Currently must remain private. =/

### To Reproduce

1. Within the template, add an entry to the templated `.gitignore`
2. Commit and tag the template repo.
3. Update templated project(s).
4. Within the template, remove the entry from the templated `.gitignore`
5. Commit and tag the template repo.
6. Update templated project(s) and observe the error.

### Logs

```console
ProcessExecutionError: | Unexpected exit code: 1
Stderr:                | The following paths are ignored by one of your .gitignore files:
                       | .env
```

The stack trace points to the `git add` command here:

https://github.com/copier-org/copier/blob/v9.3.1/copier/main.py#L1002

### Expected behavior

Copier should force-add the file and proceed without error.

### Screenshots/screencasts/logs

_No response_

### Operating system

macOS

### Operating system distribution and version

Sonoma 14.6.1

### Copier version

9.3.1

### Python version

Python 3.12

### Installation method

pipx+pypi

### Additional context

The stack trace points to the `git add` command here:

https://github.com/copier-org/copier/blob/v9.3.1/copier/main.py#L1002

FWIW, `git add` has a flag `-f, --force` to "Allow adding otherwise ignored files".

As a workaround, the update completes successfully when I exclude the un-ignored file from the update: `copier update --exclude .env`

Pull request description:
fix: don't crash when file is removed from a templated `.gitignore`
resolves #1773

Patch:
diff --git a/copier/main.py b/copier/main.py
index 925d758..351e42f 100644
--- a/copier/main.py
+++ b/copier/main.py
@@ -1041,7 +1041,7 @@ class Worker:
                     f = Path(filename)
                     f.parent.mkdir(parents=True, exist_ok=True)
                     f.touch((subproject_top / filename).stat().st_mode)
-                    git("add", filename)
+                    git("add", "--force", filename)
                 self._git_commit("add new empty files")
                 # Extract diff between temporary destination and real
                 # destination
diff --git a/poetry.lock b/poetry.lock
index 961a696..642d2c5 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -290,13 +290,13 @@ packaging = ">=20.9"
 
 [[package]]
 name = "eval-type-backport"
-version = "0.2.0"
+version = "0.2.2"
 description = "Like `typing._eval_type`, but lets older Python versions use newer typing features."
 optional = false
 python-versions = ">=3.8"
 files = [
-    {file = "eval_type_backport-0.2.0-py3-none-any.whl", hash = "sha256:ac2f73d30d40c5a30a80b8739a789d6bb5e49fdffa66d7912667e2015d9c9933"},
-    {file = "eval_type_backport-0.2.0.tar.gz", hash = "sha256:68796cfbc7371ebf923f03bdf7bef415f3ec098aeced24e054b253a0e78f7b37"},
+    {file = "eval_type_backport-0.2.2-py3-none-any.whl", hash = "sha256:cb6ad7c393517f476f96d456d0412ea80f0a8cf96f6892834cd9340149111b0a"},
+    {file = "eval_type_backport-0.2.2.tar.gz", hash = "sha256:f0576b4cf01ebb5bd358d02314d31846af5e07678387486e2c798af0e7d849c1"},
 ]
 
 [package.extras]


Test patch:
diff --git a/tests/test_updatediff.py b/tests/test_updatediff.py
index 4105318..4a6ea3f 100644
--- a/tests/test_updatediff.py
+++ b/tests/test_updatediff.py
@@ -1704,3 +1704,67 @@ def test_update_git_submodule(tmp_path_factory: pytest.TempPathFactory) -> None:
     assert (dst / "sub" / ".copier-answers.yml").is_file()
     assert (dst / "sub" / "version.txt").is_file()
     assert (dst / "sub" / "version.txt").read_text() == "v2"
+
+
+def test_gitignore_file_unignored(
+    tmp_path_factory: pytest.TempPathFactory,
+) -> None:
+    env_file = ".env"
+    gitignore_file = ".gitignore"
+    answers_file = ".copier-answers.yml"
+
+    # Template in v1 has a file with a single line;
+    # in v2 it changes that line.
+    # Meanwhile, downstream project made the same change.
+    src, dst = map(tmp_path_factory.mktemp, ("src", "dst"))
+
+    # First, create the template with an initial file
+    build_file_tree(
+        {
+            (src / gitignore_file): "",
+            (src / env_file): "",
+            (src / "{{_copier_conf.answers_file}}.jinja"): (
+                "{{_copier_answers|to_nice_yaml}}"
+            ),
+        }
+    )
+
+    with local.cwd(src):
+        git_init("hello template")
+        git("tag", "v1")
+
+    # Generate the project a first time, assert the file exists
+    run_copy(str(src), dst)
+    for f in (env_file, gitignore_file):
+        assert (dst / f).exists()
+    assert "_commit: v1" in (dst / answers_file).read_text()
+
+    # Start versioning the generated project
+    with local.cwd(dst):
+        git_init("hello project")
+
+    # Add a file to the `.gitignore` file
+    with local.cwd(src):
+        Path(gitignore_file).write_text(env_file)
+        git("commit", "-am", "ignore file")
+        git("tag", "v2")
+
+    # Update the generated project
+    run_update(dst_path=dst, overwrite=True)
+    assert "_commit: v2" in (dst / ".copier-answers.yml").read_text()
+
+    # Commit project changes.
+    with local.cwd(dst):
+        git("commit", "-am", "update to template v2")
+
+    # Remove the file previously added to `.gitignore`
+    with local.cwd(src):
+        Path(gitignore_file).write_text("")
+        git("commit", "-am", "un-ignore file")
+        git("tag", "v3")
+
+    # Update the generated project.
+    # This would fail if `git add` was called without the `--force` flag;
+    # Otherwise, it should succeed.
+    run_update(dst_path=dst, overwrite=True)
+    assert "_commit: v3" in (dst / ".copier-answers.yml").read_text()