Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 0 additions & 20 deletions copier/_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,6 @@
from collections.abc import Callable, Iterable
from pathlib import Path
from textwrap import dedent
from typing import Literal, cast

import yaml
from plumbum import LocalPath, cli, colors
Expand Down Expand Up @@ -395,23 +394,6 @@ class CopierUpdateSubApp(_Subcommand):
"""
)

conflict = cli.SwitchAttr(
["-o", "--conflict"],
cli.Set("rej", "inline"),
default="inline",
help=(
"Behavior on conflict: Create .rej files, or add inline conflict markers."
),
)
context_lines = cli.SwitchAttr(
["-c", "--context-lines"],
int,
default=3,
help=(
"Lines of context to use for detecting conflicts. Increase for "
"accuracy, decrease for resilience."
),
)
defaults = cli.Flag(
["-l", "-f", "--defaults"],
help="Use default answers to questions, which might be null if not specified.",
Expand Down Expand Up @@ -456,8 +438,6 @@ def inner() -> None:
overwrite=True,
pretend=self.pretend,
quiet=self.quiet,
conflict=cast(Literal["rej", "inline"], self.conflict),
context_lines=self.context_lines,
unsafe=self.unsafe,
skip_answered=self.skip_answered,
skip_tasks=self.skip_tasks,
Expand Down
542 changes: 191 additions & 351 deletions copier/_main.py

Large diffs are not rendered by default.

88 changes: 0 additions & 88 deletions copier/_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
import errno
import os
import platform
import re
import stat
import sys
from collections.abc import Callable, Iterator
Expand All @@ -19,7 +18,6 @@

import colorama
from packaging.version import Version
from pathspec.patterns.gitwildmatch import GitWildMatchPattern
from pydantic import StrictBool

colorama.just_fix_windows_console()
Expand Down Expand Up @@ -178,92 +176,6 @@ def handle_remove_readonly(
raise


_re_whitespace = re.compile(r"^\s+|\s+$")


def normalize_git_path(path: str) -> str:
r"""Convert weird characters returned by Git to normal UTF-8 path strings.

A filename like âñ will be reported by Git as "\\303\\242\\303\\261" (octal
notation).
Similarly, a filename like "<tab>foo\b<lf>ar" will be reported as "\tfoo\\b\nar".
This can be disabled with `git config core.quotepath off`.

Args:
path: The Git path to normalize.

Returns:
str: The normalized Git path.
"""
# Remove surrounding quotes
if path[0] == path[-1] == '"':
path = path[1:-1]
# Repair double-quotes
path = path.replace('\\"', '"')
# Unescape escape characters
path = path.encode("latin-1", "backslashreplace").decode("unicode-escape")
# Convert octal to utf8
return path.encode("latin-1", "backslashreplace").decode("utf-8")


def escape_git_path(path: str) -> str:
"""Escape paths that will be used as literal gitwildmatch patterns.

If the path was returned by a Git command, it should be unescaped completely.
``normalize_git_path`` can be used for this purpose.

Args:
path: The Git path to escape.

Returns:
str: The escaped Git path.
"""
# Prior to PathSpec v1.1.0, `GitWildMatchPattern.escape` does not escape backslashes
# or trailing whitespace.
# TODO: Remove this workaround when support for PathSpec prior to v1.1.0 is dropped.
if GitWildMatchPattern.escape("\\") == "\\":
path = path.replace("\\", "\\\\")
path = GitWildMatchPattern.escape(path)
return _re_whitespace.sub(
lambda match: "".join(f"\\{whitespace}" for whitespace in match.group()),
path,
)


def get_git_objects_dir(path: Path) -> Path:
"""Get the absolute path of a Git repository's objects directory."""
# FIXME: A lazy import is currently necessary to avoid circular imports with
# `errors.py`.
from ._vcs import get_git

git = get_git()
return path.joinpath(
git(
"-C",
path,
"rev-parse",
"--git-path",
"objects",
).strip()
).absolute()


def set_git_alternates(*repos: Path, path: Path = Path()) -> None:
"""Set Git alternates to borrow Git objects from other repositories.

Alternates are paths of other repositories' object directories written to
`$GIT_DIR/objects/info/alternates` and delimited by the newline character.

Args:
*repos: The paths of repositories from which to borrow Git objects.
path: The path of the repository where to set Git alternates. Defaults
to the current working directory.
"""
alternates_file = get_git_objects_dir(path) / "info" / "alternates"
alternates_file.parent.mkdir(parents=True, exist_ok=True)
alternates_file.write_bytes(b"\n".join(map(bytes, map(get_git_objects_dir, repos))))


def scantree(path: str, follow_symlinks: bool) -> Iterator[os.DirEntry[str]]:
"""A recursive extension of `os.scandir`."""
for entry in os.scandir(path):
Expand Down
37 changes: 0 additions & 37 deletions docs/configuring.md
Original file line number Diff line number Diff line change
Expand Up @@ -835,43 +835,6 @@ will delete that folder.
Copier will never delete the folder if it didn't create it. For this reason, when
running `copier update`, this setting has no effect.

!!! info

Not supported in `copier.yml`.

### `conflict`

- Format: `Literal["rej", "inline"]`
- CLI flags: `-o`, `--conflict` (only available in `copier update` subcommand)
- Default value: `inline`

When updating a project, sometimes Copier doesn't know what to do with a diff code hunk.
This option controls the output format if this happens. Using `rej`, creates `*.rej`
files that contain the unresolved diffs. The `inline` option (default) includes the diff
code hunk in the file itself, similar to the behavior of `git merge`.

!!! info

Not supported in `copier.yml`.

### `context_lines`

- Format: `Int`
- CLI flags: `-c`, `--context-lines` (only available in `copier update` subcommand)
- Default value: `1`

During a project update, Copier needs to compare the template evolution with the
subproject evolution. This way, it can detect what changed, where and how to merge those
changes. [Refer here for more details on this process](updating.md).

The more lines you use, the more accurate Copier will be when detecting conflicts. But
you will also have more conflicts to solve by yourself. FWIW, Git uses 3 lines by
default.

The less lines you use, the less conflicts you will have. However, Copier will not be so
accurate and could even move lines around if the file it's comparing has several similar
code chunks.

!!! info

Not supported in `copier.yml`.
Expand Down
2 changes: 0 additions & 2 deletions docs/creating.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,8 +108,6 @@ Attributes:
| ------------------ | ------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `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. |
| `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. |
| `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. |
| `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. |
| `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. |
| `defaults` | `bool` | When `True`, use default answers to questions.<br>See the [`defaults`](configuring.md#defaults) setting for related information. |
| `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. |
Expand Down
105 changes: 42 additions & 63 deletions docs/updating.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,15 +38,11 @@ other Git ref you want.

When updating, Copier will do its best to respect your project evolution by using the
answers you provided when copied last time. However, sometimes it's impossible for
Copier to know what to do with a diff code hunk. In those cases, copier handles the
conflict in one of two ways, controlled with the `--conflict` option:

- `--conflict rej`: Creates a separate `.rej` file for each file with conflicts. These
files contain the unresolved diffs.
- `--conflict inline` (default): Updates the file with conflict markers. This is quite
similar to the conflict markers created when a `git merge` command encounters a
conflict. For more information, see the "Checking Out Conflicts" section of the
[`git` documentation](https://git-scm.com/book/en/v2/Git-Tools-Advanced-Merging).
Copier to know how to merge the changes from the evolved template into the evolved
project. In those cases, Copier updates a conflicting file with conflict markers in the
same ways as a `git merge` command encounters conflicts; in fact, Copier uses
`git merge` internally. For more information, see the "Checking Out Conflicts" section
of the [`git` documentation](https://git-scm.com/book/en/v2/Git-Tools-Advanced-Merging).

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

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

## Preventing Commit of Merge Conflicts

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

```yaml title=".pre-commit-config.yaml"
repos:
Expand All @@ -74,29 +68,6 @@ repos:
args: [--assume-in-merge]
```

If you use `--conflict rej` then you need to review and remove all generated `.rej`
files:

```yaml title=".pre-commit-config.yaml"
repos:
- repo: local
hooks:
# Prevent committing .rej files
- id: forbidden-files
name: forbidden files
entry:
found Copier update rejection files; review and remove them before
merging.
language: fail
files: "\\.rej$"
```

!!! note

For projects that use both `rej` and `inline` depending on each user's preference,
you can add both hooks to your `pre-commit-config.yaml` file, making sure that no
unresolved merge conflicts are committed.

## Never change the answers file manually

!!! important
Expand Down Expand Up @@ -151,53 +122,56 @@ graph TD

%% nodes ----------------------------------------------------------
template_repo("template repository")
template_current("/tmp/template<br>(current tag)")
template_latest("/tmp/template<br>(latest tag)")
template_current("/tmp/template-old<br>(current tag)")
template_latest("/tmp/template-new<br>(latest tag)")

project_regen("/tmp/project<br>(fresh, current version)")
project_regen_current("/tmp/project-old<br>(fresh, current version)")
project_regen_latest("/tmp/project-new<br>(fresh, latest version)")
project_current("current project")
project_half("half migrated<br>project")
project_updated("updated project")
project_applied("updated project<br>(diff applied)")
project_full("fully updated<br>and migrated project")

update["update current<br>project in-place<br>(prompting)<br>+ run tasks again"]
compare["compare to get diff"]
apply["apply diff"]

diff("diff")
update["3-way merge"]
regen_current["generate and run tasks<br>& apply pre-migrations"]
regen_latest["generate and run tasks"]

%% edges ----------------------------------------------------------
template_repo --> |git clone| template_current
template_repo --> |git clone| template_latest

template_current --> |generate and run tasks| project_regen
project_current --> compare
template_current --> regen_current
project_current .-> |use answers| regen_current
regen_current --> project_regen_current
template_latest --> regen_latest
regen_latest --> project_regen_latest
project_current --> |apply pre-migrations| project_half
project_regen --> compare
project_half .-> |use answers| regen_latest
project_half --> update
template_latest --> update
project_regen_current --> update
project_regen_latest --> update
update --> project_updated
compare --> diff
diff --> apply
project_updated --> apply
apply --> project_applied
project_applied --> |apply post-migrations| project_full
project_updated --> |apply post-migrations| project_full

%% style ----------------------------------------------------------
classDef blackborder stroke:#000;
class compare,update,apply blackborder;
class regen_current,regen_latest,update blackborder;
```

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

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

### Handling of deleted paths

Expand Down Expand Up @@ -242,10 +216,15 @@ branch. The following strategies won't work:

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

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

```shell
git merge --abort
```

or

```shell
git reset # throw away merge conflict information
git checkout . # restore modified files
Expand Down
25 changes: 25 additions & 0 deletions tests/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -193,3 +193,28 @@ def git_init(message: str = "hello world") -> None:
git("init")
git("add", ".")
git("commit", "-m", message)


def normalize_git_path(path: str) -> str:
r"""Convert weird characters returned by Git to normal UTF-8 path strings.

A filename like âñ will be reported by Git as "\\303\\242\\303\\261" (octal
notation).
Similarly, a filename like "<tab>foo\b<lf>ar" will be reported as "\tfoo\\b\nar".
This can be disabled with `git config core.quotepath off`.

Args:
path: The Git path to normalize.

Returns:
str: The normalized Git path.
"""
# Remove surrounding quotes
if path[0] == path[-1] == '"':
path = path[1:-1]
# Repair double-quotes
path = path.replace('\\"', '"')
# Unescape escape characters
path = path.encode("latin-1", "backslashreplace").decode("unicode-escape")
# Convert octal to utf8
return path.encode("latin-1", "backslashreplace").decode("utf-8")
Loading
Loading