Skip to content

Commit 9968eda

Browse files
committed
feat(updating)!: introduce new update algorithm based on git merge
BREAKING CHANGE: The `-o,--conflict` flag is removed, as `git merge` only supports inline conflicts. Omit the flag and corresponding API parameter as of now. BREAKING CHANGE: The `-c,--context-lines` flag is removed, as `git merge` does not support configurable context sizes. Omit the flag and corresponding API parameter as of now. BREAKING CHANGE: Inline conflict marker labels are different, as `git merge` does not support their customization. BREAKING CHANGE: `gitattributes` settings affect Copier's internal `git merge` call.
1 parent 02e1574 commit 9968eda

12 files changed

Lines changed: 331 additions & 803 deletions

copier/_cli.py

Lines changed: 0 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -395,23 +395,6 @@ class CopierUpdateSubApp(_Subcommand):
395395
"""
396396
)
397397

398-
conflict = cli.SwitchAttr(
399-
["-o", "--conflict"],
400-
cli.Set("rej", "inline"),
401-
default="inline",
402-
help=(
403-
"Behavior on conflict: Create .rej files, or add inline conflict markers."
404-
),
405-
)
406-
context_lines = cli.SwitchAttr(
407-
["-c", "--context-lines"],
408-
int,
409-
default=3,
410-
help=(
411-
"Lines of context to use for detecting conflicts. Increase for "
412-
"accuracy, decrease for resilience."
413-
),
414-
)
415398
defaults = cli.Flag(
416399
["-l", "-f", "--defaults"],
417400
help="Use default answers to questions, which might be null if not specified.",
@@ -456,8 +439,6 @@ def inner() -> None:
456439
overwrite=True,
457440
pretend=self.pretend,
458441
quiet=self.quiet,
459-
conflict=cast(Literal["rej", "inline"], self.conflict),
460-
context_lines=self.context_lines,
461442
unsafe=self.unsafe,
462443
skip_answered=self.skip_answered,
463444
skip_tasks=self.skip_tasks,

copier/_main.py

Lines changed: 166 additions & 346 deletions
Large diffs are not rendered by default.

copier/_tools.py

Lines changed: 0 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
import errno
66
import os
77
import platform
8-
import re
98
import stat
109
import sys
1110
from collections.abc import Callable, Iterator
@@ -19,7 +18,6 @@
1918

2019
import colorama
2120
from packaging.version import Version
22-
from pathspec.patterns.gitwildmatch import GitWildMatchPattern
2321
from pydantic import StrictBool
2422

2523
colorama.just_fix_windows_console()
@@ -178,92 +176,6 @@ def handle_remove_readonly(
178176
raise
179177

180178

181-
_re_whitespace = re.compile(r"^\s+|\s+$")
182-
183-
184-
def normalize_git_path(path: str) -> str:
185-
r"""Convert weird characters returned by Git to normal UTF-8 path strings.
186-
187-
A filename like âñ will be reported by Git as "\\303\\242\\303\\261" (octal
188-
notation).
189-
Similarly, a filename like "<tab>foo\b<lf>ar" will be reported as "\tfoo\\b\nar".
190-
This can be disabled with `git config core.quotepath off`.
191-
192-
Args:
193-
path: The Git path to normalize.
194-
195-
Returns:
196-
str: The normalized Git path.
197-
"""
198-
# Remove surrounding quotes
199-
if path[0] == path[-1] == '"':
200-
path = path[1:-1]
201-
# Repair double-quotes
202-
path = path.replace('\\"', '"')
203-
# Unescape escape characters
204-
path = path.encode("latin-1", "backslashreplace").decode("unicode-escape")
205-
# Convert octal to utf8
206-
return path.encode("latin-1", "backslashreplace").decode("utf-8")
207-
208-
209-
def escape_git_path(path: str) -> str:
210-
"""Escape paths that will be used as literal gitwildmatch patterns.
211-
212-
If the path was returned by a Git command, it should be unescaped completely.
213-
``normalize_git_path`` can be used for this purpose.
214-
215-
Args:
216-
path: The Git path to escape.
217-
218-
Returns:
219-
str: The escaped Git path.
220-
"""
221-
# Prior to PathSpec v1.1.0, `GitWildMatchPattern.escape` does not escape backslashes
222-
# or trailing whitespace.
223-
# TODO: Remove this workaround when support for PathSpec prior to v1.1.0 is dropped.
224-
if GitWildMatchPattern.escape("\\") == "\\":
225-
path = path.replace("\\", "\\\\")
226-
path = GitWildMatchPattern.escape(path)
227-
return _re_whitespace.sub(
228-
lambda match: "".join(f"\\{whitespace}" for whitespace in match.group()),
229-
path,
230-
)
231-
232-
233-
def get_git_objects_dir(path: Path) -> Path:
234-
"""Get the absolute path of a Git repository's objects directory."""
235-
# FIXME: A lazy import is currently necessary to avoid circular imports with
236-
# `errors.py`.
237-
from ._vcs import get_git
238-
239-
git = get_git()
240-
return path.joinpath(
241-
git(
242-
"-C",
243-
path,
244-
"rev-parse",
245-
"--git-path",
246-
"objects",
247-
).strip()
248-
).absolute()
249-
250-
251-
def set_git_alternates(*repos: Path, path: Path = Path()) -> None:
252-
"""Set Git alternates to borrow Git objects from other repositories.
253-
254-
Alternates are paths of other repositories' object directories written to
255-
`$GIT_DIR/objects/info/alternates` and delimited by the newline character.
256-
257-
Args:
258-
*repos: The paths of repositories from which to borrow Git objects.
259-
path: The path of the repository where to set Git alternates. Defaults
260-
to the current working directory.
261-
"""
262-
alternates_file = get_git_objects_dir(path) / "info" / "alternates"
263-
alternates_file.parent.mkdir(parents=True, exist_ok=True)
264-
alternates_file.write_bytes(b"\n".join(map(bytes, map(get_git_objects_dir, repos))))
265-
266-
267179
def scantree(path: str, follow_symlinks: bool) -> Iterator[os.DirEntry[str]]:
268180
"""A recursive extension of `os.scandir`."""
269181
for entry in os.scandir(path):

docs/configuring.md

Lines changed: 0 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -835,43 +835,6 @@ will delete that folder.
835835
Copier will never delete the folder if it didn't create it. For this reason, when
836836
running `copier update`, this setting has no effect.
837837

838-
!!! info
839-
840-
Not supported in `copier.yml`.
841-
842-
### `conflict`
843-
844-
- Format: `Literal["rej", "inline"]`
845-
- CLI flags: `-o`, `--conflict` (only available in `copier update` subcommand)
846-
- Default value: `inline`
847-
848-
When updating a project, sometimes Copier doesn't know what to do with a diff code hunk.
849-
This option controls the output format if this happens. Using `rej`, creates `*.rej`
850-
files that contain the unresolved diffs. The `inline` option (default) includes the diff
851-
code hunk in the file itself, similar to the behavior of `git merge`.
852-
853-
!!! info
854-
855-
Not supported in `copier.yml`.
856-
857-
### `context_lines`
858-
859-
- Format: `Int`
860-
- CLI flags: `-c`, `--context-lines` (only available in `copier update` subcommand)
861-
- Default value: `1`
862-
863-
During a project update, Copier needs to compare the template evolution with the
864-
subproject evolution. This way, it can detect what changed, where and how to merge those
865-
changes. [Refer here for more details on this process](updating.md).
866-
867-
The more lines you use, the more accurate Copier will be when detecting conflicts. But
868-
you will also have more conflicts to solve by yourself. FWIW, Git uses 3 lines by
869-
default.
870-
871-
The less lines you use, the less conflicts you will have. However, Copier will not be so
872-
accurate and could even move lines around if the file it's comparing has several similar
873-
code chunks.
874-
875838
!!! info
876839

877840
Not supported in `copier.yml`.

docs/creating.md

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -108,8 +108,6 @@ Attributes:
108108
| ------------------ | ------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
109109
| `answers_file` | `PurePath` | The path for [the answers file](configuring.md#the-copier-answersyml-file) relative to `dst_path`.<br>See the [`answers_file`](configuring.md#answers_file) setting for related information. |
110110
| `cleanup_on_error` | `bool` | When `True`, delete `dst_path` if there's an error.<br>See the [`cleanup_on_error`](configuring.md#cleanup_on_error) setting for related information. |
111-
| `conflict` | `Literal["inline", "rej"]` | The output format of a diff code hunk when [updating][updating-a-project] a file yields conflicts.<br>See the [`conflict`](configuring.md#conflict) setting for related information. |
112-
| `context_lines` | `PositiveInt` | Lines of context to consider when solving conflicts in updates.<br>See the [`context_lines`](configuring.md#context_lines) setting for related information. |
113111
| `data` | `dict[str, Any]` | Answers to the questionnaire, defined in the template, provided via CLI (`-d,--data`) or API (`data`).<br>See the [`data`](configuring.md#data) setting for related information.<br>⚠️ May contain secret answers. |
114112
| `defaults` | `bool` | When `True`, use default answers to questions.<br>See the [`defaults`](configuring.md#defaults) setting for related information. |
115113
| `dst_path` | `PurePath` | Destination path where to render the subproject.<br>⚠️ When [updating a project](updating.md), it may be a temporary directory, as Copier's update algorithm generates fresh copies using the old and new template versions in temporary locations. |

docs/updating.md

Lines changed: 41 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -38,15 +38,11 @@ other Git ref you want.
3838

3939
When updating, Copier will do its best to respect your project evolution by using the
4040
answers you provided when copied last time. However, sometimes it's impossible for
41-
Copier to know what to do with a diff code hunk. In those cases, copier handles the
42-
conflict in one of two ways, controlled with the `--conflict` option:
43-
44-
- `--conflict rej`: Creates a separate `.rej` file for each file with conflicts. These
45-
files contain the unresolved diffs.
46-
- `--conflict inline` (default): Updates the file with conflict markers. This is quite
47-
similar to the conflict markers created when a `git merge` command encounters a
48-
conflict. For more information, see the "Checking Out Conflicts" section of the
49-
[`git` documentation](https://git-scm.com/book/en/v2/Git-Tools-Advanced-Merging).
41+
Copier to know how to merge the changes from the evolved template into the evolved
42+
project. In those cases, Copier updates a conflicting file with conflict markers in the
43+
same ways as a `git merge` command encounters conflicts; in fact, Copier uses
44+
`git merge` internally. For more information, see the "Checking Out Conflicts" section
45+
of the [`git` documentation](https://git-scm.com/book/en/v2/Git-Tools-Advanced-Merging).
5046

5147
If the update results in conflicts, _you should review those manually_ before
5248
committing.
@@ -56,13 +52,11 @@ Git history, but if you aren't careful, it's easy to make mistakes.
5652

5753
That's why the recommended way to prevent these mistakes is to add a
5854
[pre-commit](https://pre-commit.com/) (or equivalent) hook that forbids committing
59-
conflict files or markers. The recommended hook configuration depends on the `conflict`
60-
setting you use.
55+
conflict markers.
6156

6257
## Preventing Commit of Merge Conflicts
6358

64-
If you use `--conflict inline` (the default) then you need to check for conflicts
65-
markers in your files:
59+
You need to check for conflict markers in your files:
6660

6761
```yaml title=".pre-commit-config.yaml"
6862
repos:
@@ -74,29 +68,6 @@ repos:
7468
args: [--assume-in-merge]
7569
```
7670
77-
If you use `--conflict rej` then you need to review and remove all generated `.rej`
78-
files:
79-
80-
```yaml title=".pre-commit-config.yaml"
81-
repos:
82-
- repo: local
83-
hooks:
84-
# Prevent committing .rej files
85-
- id: forbidden-files
86-
name: forbidden files
87-
entry:
88-
found Copier update rejection files; review and remove them before
89-
merging.
90-
language: fail
91-
files: "\\.rej$"
92-
```
93-
94-
!!! note
95-
96-
For projects that use both `rej` and `inline` depending on each user's preference,
97-
you can add both hooks to your `pre-commit-config.yaml` file, making sure that no
98-
unresolved merge conflicts are committed.
99-
10071
## Never change the answers file manually
10172
10273
!!! important
@@ -151,53 +122,55 @@ graph TD
151122
152123
%% nodes ----------------------------------------------------------
153124
template_repo("template repository")
154-
template_current("/tmp/template<br>(current tag)")
155-
template_latest("/tmp/template<br>(latest tag)")
125+
template_current("/tmp/template-old<br>(current tag)")
126+
template_latest("/tmp/template-new<br>(latest tag)")
156127
157-
project_regen("/tmp/project<br>(fresh, current version)")
128+
project_regen_current("/tmp/project-old<br>(fresh, current version)")
129+
project_regen_latest("/tmp/project-new<br>(fresh, latest version)")
158130
project_current("current project")
159131
project_half("half migrated<br>project")
160132
project_updated("updated project")
161-
project_applied("updated project<br>(diff applied)")
162133
project_full("fully updated<br>and migrated project")
163134
164-
update["update current<br>project in-place<br>(prompting)<br>+ run tasks again"]
165-
compare["compare to get diff"]
166-
apply["apply diff"]
167-
168-
diff("diff")
135+
update["3-way merge<br>& run tasks again"]
136+
regen_current["generate and run tasks"]
137+
regen_latest["generate and run tasks"]
169138
170139
%% edges ----------------------------------------------------------
171140
template_repo --> |git clone| template_current
172141
template_repo --> |git clone| template_latest
173142
174-
template_current --> |generate and run tasks| project_regen
175-
project_current --> compare
143+
template_current --> regen_current
144+
project_current .-> |use answers| regen_current
145+
regen_current --> project_regen_current
146+
template_latest --> regen_latest
147+
regen_latest --> project_regen_latest
176148
project_current --> |apply pre-migrations| project_half
177-
project_regen --> compare
149+
project_half .-> |use answers| regen_latest
178150
project_half --> update
179-
template_latest --> update
151+
project_regen_current --> update
152+
project_regen_latest --> update
180153
update --> project_updated
181-
compare --> diff
182-
diff --> apply
183-
project_updated --> apply
184-
apply --> project_applied
185-
project_applied --> |apply post-migrations| project_full
154+
project_updated --> |apply post-migrations| project_full
186155
187156
%% style ----------------------------------------------------------
188157
classDef blackborder stroke:#000;
189-
class compare,update,apply blackborder;
158+
class regen_current,regen_latest,update blackborder;
190159
```
191160

192161
As you can see here, `copier` does several things:
193162

194-
- It regenerates a fresh project from the current template version.
195-
- Then, it compares both version to get the diff from "fresh project" to "current
196-
project".
197-
- Now, it applies pre-migrations to your project, and updates the current project with
198-
the latest template changes (asking for confirmation).
199-
- Finally, it re-applies the previously obtained diff and then runs the
200-
post-migrations.
163+
- Regenerate the project fresh from the **current** template version, using the
164+
project's existing answers – this becomes the merge-base.
165+
- Regenerate the project fresh from the **latest** template version, using the same
166+
answers (with pre-migrations applied to the project beforehand).
167+
- Build a synthetic Git commit graph from these three states: the current-version
168+
regeneration (common ancestor), the latest-version regeneration, and the actual
169+
current project.
170+
- Perform a Git 3-way merge (using `git merge`) of the latest-version regeneration
171+
into the current project, using the current-version regeneration as their common
172+
ancestor – conflicts are marked like any normal `git merge` conflict.
173+
- Run post-migrations on the merged result to produce the fully updated project.
201174

202175
### Handling of deleted paths
203176

@@ -242,10 +215,15 @@ branch. The following strategies won't work:
242215

243216
- `git checkout <branch>` – _error: you need to resolve your current index first_
244217
- `git checkout .` – _error: path '&lt;filename&gt;' is unmerged_
245-
- `git merge --abort` – _fatal: There is no merge to abort (MERGE_HEAD missing)_
246218

247219
Here is what you can do using Git in the terminal to throw away all changes:
248220

221+
```shell
222+
git merge --abort
223+
```
224+
225+
or
226+
249227
```shell
250228
git reset # throw away merge conflict information
251229
git checkout . # restore modified files

tests/helpers.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,3 +193,28 @@ def git_init(message: str = "hello world") -> None:
193193
git("init")
194194
git("add", ".")
195195
git("commit", "-m", message)
196+
197+
198+
def normalize_git_path(path: str) -> str:
199+
r"""Convert weird characters returned by Git to normal UTF-8 path strings.
200+
201+
A filename like âñ will be reported by Git as "\\303\\242\\303\\261" (octal
202+
notation).
203+
Similarly, a filename like "<tab>foo\b<lf>ar" will be reported as "\tfoo\\b\nar".
204+
This can be disabled with `git config core.quotepath off`.
205+
206+
Args:
207+
path: The Git path to normalize.
208+
209+
Returns:
210+
str: The normalized Git path.
211+
"""
212+
# Remove surrounding quotes
213+
if path[0] == path[-1] == '"':
214+
path = path[1:-1]
215+
# Repair double-quotes
216+
path = path.replace('\\"', '"')
217+
# Unescape escape characters
218+
path = path.encode("latin-1", "backslashreplace").decode("unicode-escape")
219+
# Convert octal to utf8
220+
return path.encode("latin-1", "backslashreplace").decode("utf-8")

0 commit comments

Comments
 (0)