Skip to content

Commit

Permalink
feat: experimental inline conflict markers when updating
Browse files Browse the repository at this point in the history
Fixes #613 

Based on #627, but:

* Supports both old (`.rej` files) and new (inline markers) conflict behavior

* Update a test to test both rej and inline conflict resolution

* Update some tests to explicitly specify "rej" conflict mode

* Add documentation for the two conflict modes

* keeping only 3-way merge

Co-authored-by: Oleh Prypin <oprypin@users.noreply.github.com>
Co-authored-by:
Thierry Guillemot <tguillemot@users.noreply.github.com>
Co-authored-by: Barry Hart <barry.hart@zoro.com>
Co-authored-by: Jairo Llopis <yajo.sk8@gmail.com>
Co-authored-by: Timothée Mazzucotelli <pawamoy@pm.me>
  • Loading branch information
5 people authored Nov 14, 2022
1 parent be900ed commit 504301c
Show file tree
Hide file tree
Showing 6 changed files with 238 additions and 49 deletions.
10 changes: 10 additions & 0 deletions copier/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,15 @@ class CopierApp(cli.Application):
"to find the answers file"
),
)
conflict: cli.SwitchAttr = cli.SwitchAttr(
["-o", "--conflict"],
cli.Set("rej", "inline"),
default="rej",
help=(
"Behavior on conflict: rej=Create .rej file, inline=inline conflict "
"markers"
),
)
exclude: cli.SwitchAttr = cli.SwitchAttr(
["-x", "--exclude"],
str,
Expand Down Expand Up @@ -216,6 +225,7 @@ def _worker(self, src_path: OptStr = None, dst_path: str = ".", **kwargs) -> Wor
src_path=src_path,
vcs_ref=self.vcs_ref,
use_prereleases=self.prereleases,
conflict=self.conflict,
**kwargs,
)

Expand Down
166 changes: 138 additions & 28 deletions copier/main.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
"""Main functions and classes, used to generate or update projects."""

import contextlib
import os
import platform
import subprocess
import sys
Expand All @@ -9,8 +11,8 @@
from functools import partial
from itertools import chain
from pathlib import Path
from shutil import rmtree
from typing import Callable, Iterable, List, Mapping, Optional, Sequence
from shutil import copyfile, rmtree
from typing import Callable, Iterable, List, Mapping, Optional, Sequence, Set
from unicodedata import normalize

from jinja2.loaders import FileSystemLoader
Expand Down Expand Up @@ -45,6 +47,19 @@
else:
from backports.cached_property import cached_property

# Backport of `shutil.copytree` for python 3.7 to accept `dirs_exist_ok` argument
if sys.version_info >= (3, 8):
from shutil import copytree
else:
from distutils.dir_util import copy_tree

def copytree(src: Path, dst: Path, dirs_exist_ok: bool = False):
"""Backport of `shutil.copytree` with `dirs_exist_ok` argument.
Can be remove once python 3.7 dropped.
"""
copy_tree(str(src), str(dst))


@dataclass(config=ConfigDict(extra=Extra.forbid))
class Worker:
Expand Down Expand Up @@ -136,6 +151,9 @@ class Worker:
When `True`, disable all output.
See [quiet][].
conflict:
One of "rej" (default), "inline" (still experimental).
"""

src_path: Optional[str] = None
Expand All @@ -152,6 +170,7 @@ class Worker:
overwrite: bool = False
pretend: bool = False
quiet: bool = False
conflict: str = "rej"

def __enter__(self):
return self
Expand Down Expand Up @@ -678,21 +697,23 @@ def run_update(self) -> None:
print(
f"Updating to template version {self.template.version}", file=sys.stderr
)
self._apply_update()

def _apply_update(self):
if self.conflict == "inline":
# New implementation.
self._apply_update_inline_conflict_markers()
return

# Old implementation.
# Copy old template into a temporary destination
with TemporaryDirectory(
prefix=f"{__name__}.update_diff."
) as old_copy, TemporaryDirectory(
prefix=f"{__name__}.recopy_diff."
) as new_copy:
old_worker = replace(
self,
dst_path=old_copy,
data=self.subproject.last_answers,
defaults=True,
quiet=True,
src_path=self.subproject.template.url,
vcs_ref=self.subproject.template.commit,
)
old_worker = self._make_old_worker(old_copy)
old_worker.run_copy()
recopy_worker = replace(
self,
dst_path=new_copy,
Expand All @@ -701,7 +722,6 @@ def run_update(self) -> None:
quiet=True,
src_path=self.subproject.template.url,
)
old_worker.run_copy()
recopy_worker.run_copy()
compared = dircmp(old_copy, new_copy)
# Extract diff between temporary destination and real destination
Expand All @@ -712,15 +732,7 @@ def run_update(self) -> None:
"rev-parse",
"--show-toplevel",
).strip()
git("init", retcode=None)
git("add", ".")
git("config", "user.name", "Copier")
git("config", "user.email", "copier@copier")
# 1st commit could fail if any pre-commit hook reformats code
git("commit", "--allow-empty", "-am", "dumb commit 1", retcode=None)
git("commit", "--allow-empty", "-am", "dumb commit 2")
git("config", "--unset", "user.name")
git("config", "--unset", "user.email")
self._git_initialize_repo()
git("remote", "add", "real_dst", "file://" + subproject_top)
git("fetch", "--depth=1", "real_dst", "HEAD")
diff_cmd = git["diff-tree", "--unified=1", "HEAD...FETCH_HEAD"]
Expand All @@ -737,13 +749,7 @@ def run_update(self) -> None:
self._execute_tasks(
self.template.migration_tasks("before", self.subproject.template)
)
# Clear last answers cache to load possible answers migration
with suppress(AttributeError):
del self.answers
with suppress(AttributeError):
del self.subproject.last_answers
# Do a normal update in final destination
self.run_copy()
self._uncached_copy()
# Try to apply cached diff into final destination
with local.cwd(self.subproject.local_abspath):
apply_cmd = git["apply", "--reject", "--exclude", self.answers_relpath]
Expand All @@ -761,6 +767,110 @@ def run_update(self) -> None:
self.template.migration_tasks("after", self.subproject.template)
)

def _apply_update_inline_conflict_markers(self):
"""Implements the apply_update() method using inline conflict markers."""
# Copy old template into a temporary destination
with TemporaryDirectory(
prefix=f"{__name__}.update_diff.reference."
) as reference_dst_temp, TemporaryDirectory(
prefix=f"{__name__}.update_diff.original."
) as old_copy, TemporaryDirectory(
prefix=f"{__name__}.update_diff.merge."
) as merge_dst_temp:
# Copy reference to be used as base by merge-file
copytree(self.dst_path, reference_dst_temp, dirs_exist_ok=True)

# Compute modification from the original template to be used as other by merge-file
assert self.subproject
assert self.subproject.template
old_worker = self._make_old_worker(old_copy)
old_worker.run_copy()
with local.cwd(old_copy):
self._git_initialize_repo()

# Run pre-migration tasks
self._execute_tasks(
self.template.migration_tasks("before", self.subproject.template)
)
self._uncached_copy()

# Extract the list of files to merge
participating_files: Set[Path] = set()
for src_dir in (old_copy, reference_dst_temp):
for root, dirs, files in os.walk(src_dir, topdown=True):
if root == src_dir and ".git" in dirs:
dirs.remove(".git")
root = Path(root).relative_to(src_dir)
participating_files.update(Path(root, f) for f in files)

# Merging files
for basename in sorted(participating_files):
subfile_names = []
for subfile_kind, src_dir in [
("modified", reference_dst_temp),
("old upstream", old_copy),
("new upstream", self.dst_path),
]:
path = Path(src_dir, basename)
if path.is_file():
copyfile(path, Path(merge_dst_temp, subfile_kind))
else:
subfile_kind = os.devnull
subfile_names.append(subfile_kind)

with local.cwd(merge_dst_temp):
# https://git-scm.com/docs/git-merge-file
output = git("merge-file", "-p", *subfile_names, retcode=None)

dest_path = Path(self.dst_path, basename)
# Remove the file if it was already removed in the project
if not output and "modified" not in subfile_names:
with contextlib.suppress(FileNotFoundError):
dest_path.unlink()
else:
dest_path.parent.mkdir(parents=True, exist_ok=True)
dest_path.write_text(output)

# Run post-migration tasks
self._execute_tasks(
self.template.migration_tasks("after", self.subproject.template)
)

def _uncached_copy(self):
"""Copy template to destination without using answer cache."""
# Clear last answers cache to load possible answers migration
with suppress(AttributeError):
del self.answers
with suppress(AttributeError):
del self.subproject.last_answers
# Do a normal update in final destination
self.run_copy()

def _make_old_worker(self, old_copy):
"""Create a worker to copy the old template into a temporary destination."""
old_worker = replace(
self,
dst_path=old_copy,
data=self.subproject.last_answers,
defaults=True,
quiet=True,
src_path=self.subproject.template.url,
vcs_ref=self.subproject.template.commit,
)
return old_worker

def _git_initialize_repo(self):
"""Initialize a git repository in the current directory."""
git("init", retcode=None)
git("add", ".")
git("config", "user.name", "Copier")
git("config", "user.email", "copier@copier")
# 1st commit could fail if any pre-commit hook reformats code
git("commit", "--allow-empty", "-am", "dumb commit 1", retcode=None)
git("commit", "--allow-empty", "-am", "dumb commit 2")
git("config", "--unset", "user.name")
git("config", "--unset", "user.email")


def run_copy(
src_path: str,
Expand Down
15 changes: 15 additions & 0 deletions docs/configuring.md
Original file line number Diff line number Diff line change
Expand Up @@ -498,6 +498,21 @@ 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`
- Default value: `rej`

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. The default, `rej`, creates
`*.rej` files that contain the unresolved diffs. The `inline` option includes the diff
code hunk in the file itself, similar to the behavior of `git merge`.

!!! info

Not supported in `copier.yml`.
Expand Down
36 changes: 29 additions & 7 deletions docs/updating.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,25 +22,47 @@ 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, you will find `*.rej`
files that contain the unresolved diffs. _You should review those manually_ before
Copier to know what to do with a diff code hunk. In those cases, copier handles the
conflict one of two ways, controlled with the `--conflict` option:

- `--conflict rej` (default): Creates a separate `.rej` file for each file with
conflicts. These files contain the unresolved diffs.
- `--conflict inline` (experimental): 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).

If the update results in conflicts, _you should review those manually_ before
committing.

You probably don't want `*.rej` files in your Git history, but if you add them to
`.gitignore`, some important changes could pass unnoticed to you. That's why the
recommended way to deal with them is to _not_ add them to add a
[pre-commit](https://pre-commit.com/) (or equivalent) hook that forbids them, just like
this:
You probably don't want to lose important changes or to include merge conflicts in your
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.

## Preventing Commit of Merge Conflicts

If you use `--conflict rej` (the default):

```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 them and remove them
language: fail
files: "\\.rej$"
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.3.0
hooks:
# Prevent committing inline conflict markers
- id: check-merge-conflict
args: [--assume-in-merge]
```
## Never change the answers file manually
Expand Down
Loading

0 comments on commit 504301c

Please sign in to comment.