Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Mark conflicted files as unmerged #1396

Merged
merged 3 commits into from
Nov 22, 2023
Merged
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
39 changes: 34 additions & 5 deletions copier/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@
)
from .subproject import Subproject
from .template import Task, Template
from .tools import OS, Style, printf, readlink
from .tools import OS, Style, normalize_git_path, printf, readlink
from .types import (
MISSING,
AnyByStrDict,
Expand Down Expand Up @@ -901,13 +901,21 @@ def _apply_update(self):
apply_cmd = apply_cmd["--exclude", skip_pattern]
(apply_cmd << diff)(retcode=None)
if self.conflict == "inline":
conflicted = []
status = git("status", "--porcelain").strip().splitlines()
for line in status:
# Find merge rejections
if not (line.startswith("?? ") and line.endswith(".rej")):
# Filter merge rejections (part 1/2)
if not line.startswith("?? "):
continue
# FIXME Test with a file named '`â ñ"', see it fail, fix it
fname = line[3:-4]
# Remove "?? " prefix
fname = line[3:]
# Normalize name
fname = normalize_git_path(fname)
# Filter merge rejections (part 2/2)
if not fname.endswith(".rej"):
continue
# Remove ".rej" suffix
fname = fname[:-4]
# Undo possible non-rejected chunks
git("checkout", "--", fname)
# 3-way-merge the file directly
Expand All @@ -926,6 +934,27 @@ def _apply_update(self):
)
# Remove rejection witness
Path(f"{fname}.rej").unlink()
# Store file name for marking it as unmerged after the loop
conflicted.append(fname)
# Forcefully mark files with conflict markers as unmerged,
# see SO post: https://stackoverflow.com/questions/77391627/
# and Git docs: https://git-scm.com/docs/git-update-index#_using_index_info.
# For each file with conflict markers, we update the index to add
# higher order versions of their paths, without entries for resolved contents.
if conflicted:
input_lines = []
for line in (
git("ls-files", "--stage", *conflicted).strip().splitlines()
):
perms_sha_mode, path = line.split("\t")
perms, sha, _ = perms_sha_mode.split()
input_lines.append(f"0 {'0' * 40}\t{path}")
for mode in (1, 2, 3):
input_lines.append(f"{perms} {sha} {mode}\t{path}")
(
git["update-index", "--index-info"]
<< "\n".join(input_lines)
)()
# Trigger recursive removal of deleted files in last template version
_remove_old_files(subproject_top, compared)

Expand Down
22 changes: 22 additions & 0 deletions copier/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import errno
import os
import platform
import re
import stat
import sys
from contextlib import suppress
Expand Down Expand Up @@ -177,3 +178,24 @@
return link.readlink()
else:
return Path(os.readlink(link))


_re_octal = re.compile(r"\\([0-9]{3})\\([0-9]{3})")


Check warning on line 185 in copier/tools.py

View check run for this annotation

Codecov / codecov/patch

copier/tools.py#L185

Added line #L185 was not covered by tests
def _re_octal_replace(match: re.Match) -> str:
return bytes([int(match.group(1), 8), int(match.group(2), 8)]).decode("utf8")


def normalize_git_path(path: str) -> str:
# A filename like âñ will be reported by Git
# as "\\303\\242\\303\\261" (octal notation).
# This can be disabled with `git config core.quotepath off`.

# Remove surrounding quotes
if path[0] == path[-1] == '"':
path = path[1:-1]
# Repair double-quotes
path = path.replace('\\"', '"')
# Convert octal to utf8
return _re_octal.sub(_re_octal_replace, path)
13 changes: 13 additions & 0 deletions tests/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,3 +149,16 @@ def git_save(
git("commit", "-m", message)
if tag:
git("tag", tag)


def git_init(message="hello world") -> None:
"""Initialize a Git repository with a first commit.

Args:
message: The first commit message.
"""
git("init")
git("config", "user.name", "Copier Test")
git("config", "user.email", "test@copier")
git("add", ".")
git("commit", "-m", message)
10 changes: 1 addition & 9 deletions tests/test_subdirectory.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,7 @@

import copier

from .helpers import BRACKET_ENVOPS_JSON, SUFFIX_TMPL, build_file_tree


def git_init(message="hello world") -> None:
git("init")
git("config", "user.name", "Copier Test")
git("config", "user.email", "test@copier")
git("add", ".")
git("commit", "-m", message)
from .helpers import BRACKET_ENVOPS_JSON, SUFFIX_TMPL, build_file_tree, git_init


@pytest.fixture(scope="module")
Expand Down
16 changes: 16 additions & 0 deletions tests/test_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,12 @@
from stat import S_IREAD
from tempfile import TemporaryDirectory

import pytest
from plumbum.cmd import git
from poethepoet.app import PoeThePoet

from copier.tools import normalize_git_path


def test_types() -> None:
"""Ensure source code static typing."""
Expand All @@ -27,3 +30,16 @@ def test_temporary_directory_with_git_repo_deletion() -> None:
with TemporaryDirectory() as tmp_dir:
git("init")
assert not Path(tmp_dir).exists()


@pytest.mark.parametrize(
("path", "normalized"),
[
("readme.md", "readme.md"),
('quo\\"tes', 'quo"tes'),
('"surrounded"', "surrounded"),
("m4\\303\\2424\\303\\2614a", "m4â4ñ4a"),
],
)
def test_normalizing_git_paths(path: str, normalized: str) -> None:
assert normalize_git_path(path) == normalized
85 changes: 85 additions & 0 deletions tests/test_updatediff.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from copier.cli import CopierApp
from copier.errors import UserMessageError
from copier.main import Worker, run_copy, run_update
from copier.tools import normalize_git_path

from .helpers import (
BRACKET_ENVOPS_JSON,
Expand All @@ -21,6 +22,7 @@
SUFFIX_TMPL,
Spawn,
build_file_tree,
git_init,
)


Expand Down Expand Up @@ -983,3 +985,86 @@ def function_two():
print("Previous line lied.")
"""
)


@pytest.mark.parametrize(
"filename",
[
"README.md",
"spa ces",
# Double quotes are not supported in file names on Windows.
"qu`o'tes" if platform.system() == "Windows" else 'qu`o"tes',
"m4â4ñ4a",
],
)
def test_conflicted_files_are_marked_unmerged(
tmp_path_factory: pytest.TempPathFactory,
filename: str,
) -> None:
# Template in v1 has a file with a single line;
# in v2 it changes that line.
# Meanwhile, downstream project appended contents to the first line.
src, dst = map(tmp_path_factory.mktemp, ("src", "dst"))

# First, create the template with an initial file
build_file_tree(
{
(src / filename): "upstream version 1",
(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, defaults=True, overwrite=True)
assert (dst / filename).exists()
assert "_commit: v1" in (dst / ".copier-answers.yml").read_text()

# Start versioning the generated project
with local.cwd(dst):
git_init("hello project")

# After first commit, change the file, commit again
Path(filename).write_text("upstream version 1 + downstream")
git("commit", "-am", "updated file")

# Now change the template
with local.cwd(src):
# Update the file
Path(filename).write_text("upstream version 2")

# Commit the changes
git("add", ".", "-A")
git("commit", "-m", "change line in file")
git("tag", "v2")

# Finally, update the generated project
run_update(dst_path=dst, defaults=True, overwrite=True, conflict="inline")
assert "_commit: v2" in (dst / ".copier-answers.yml").read_text()

# Assert that the file still exists, has inline markers,
# and is reported as "unmerged" by Git.
assert (dst / filename).exists()

expected_contents = dedent(
"""\
<<<<<<< before updating
upstream version 1 + downstream
=======
upstream version 2
>>>>>>> after updating
"""
)
assert (dst / filename).read_text().splitlines() == expected_contents.splitlines()
assert not (dst / f"{filename}.rej").exists()

with local.cwd(dst):
lines = git("status", "--porcelain=v1").strip().splitlines()
assert any(
line.startswith("UU") and normalize_git_path(line[3:]) == filename
for line in lines
)