devlyn-cli 4.3.20 → 4.3.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +2 -2
- package/CLAUDE.md +2 -2
- package/README.md +1 -1
- package/bin/devlyn.js +26 -1
- package/bin/instruction-templates.json +146 -0
- package/bin/skill-history.json +14 -1
- package/config/skills/_shared/bring-in.py +810 -0
- package/config/skills/_shared/harness-store.py +18 -6
- package/config/skills/_shared/runtime-principles.md +2 -2
- package/config/skills/_shared/storage-checkout.py +117 -0
- package/config/skills/_shared/storage-client.py +1180 -0
- package/config/skills/_shared/task-complete.py +900 -88
- package/config/skills/_shared/task-completion.md +78 -6
- package/config/skills/_shared/warm-build-dirs.py +53 -3
- package/config/skills/devlyn-ideate/SKILL.md +1 -1
- package/config/skills/devlyn-ideate/references/loop.md +2 -2
- package/config/skills/devlyn-ideate/scripts/queue.py +69 -18
- package/config/storage-client-contract.json +7 -0
- package/harness/AGENTS.md +3 -3
- package/harness/CLAUDE.md +3 -3
- package/native/claude/.claude-plugin/plugin.json +1 -1
- package/native/claude/skills/_shared/bring-in.py +810 -0
- package/native/claude/skills/_shared/harness-store.py +18 -6
- package/native/claude/skills/_shared/runtime-principles.md +2 -2
- package/native/claude/skills/_shared/storage-checkout.py +117 -0
- package/native/claude/skills/_shared/storage-client.py +1180 -0
- package/native/claude/skills/_shared/task-complete.py +900 -88
- package/native/claude/skills/_shared/task-completion.md +78 -6
- package/native/claude/skills/_shared/warm-build-dirs.py +53 -3
- package/native/claude/skills/devlyn-ideate/SKILL.md +1 -1
- package/native/claude/skills/devlyn-ideate/references/loop.md +2 -2
- package/native/claude/skills/devlyn-ideate/scripts/queue.py +69 -18
- package/native/codex/.agents/plugins/marketplace.json +1 -1
- package/native/codex/plugins/devlyn/.codex-plugin/plugin.json +1 -1
- package/native/codex/plugins/devlyn/skills/_shared/bring-in.py +810 -0
- package/native/codex/plugins/devlyn/skills/_shared/harness-store.py +18 -6
- package/native/codex/plugins/devlyn/skills/_shared/runtime-principles.md +2 -2
- package/native/codex/plugins/devlyn/skills/_shared/storage-checkout.py +117 -0
- package/native/codex/plugins/devlyn/skills/_shared/storage-client.py +1180 -0
- package/native/codex/plugins/devlyn/skills/_shared/task-complete.py +900 -88
- package/native/codex/plugins/devlyn/skills/_shared/task-completion.md +78 -6
- package/native/codex/plugins/devlyn/skills/_shared/warm-build-dirs.py +53 -3
- package/native/codex/plugins/devlyn/skills/devlyn-ideate/SKILL.md +1 -1
- package/native/codex/plugins/devlyn/skills/devlyn-ideate/references/loop.md +2 -2
- package/native/codex/plugins/devlyn/skills/devlyn-ideate/scripts/queue.py +69 -18
- package/optional-skills/better-auth-setup/SKILL.md +9 -3
- package/optional-skills/better-auth-setup/references/proxy-setup.md +10 -3
- package/package.json +1 -1
- package/release.json +91 -41
|
@@ -0,0 +1,810 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Bring a commit into the owner's branch using only a protected fast-forward.
|
|
3
|
+
|
|
4
|
+
Merge policy is honored while preparing a commit with merge-tree/commit-tree, before
|
|
5
|
+
landing. Clearing mergeOptions and selecting ort at landing prevents configuration
|
|
6
|
+
from turning that fast-forward into a destructive merge; it does not bypass policy.
|
|
7
|
+
pre-merge-commit is not run: its contract requires the merged index and worktree,
|
|
8
|
+
which would require writing the owner's checkout before the commit. Message hooks
|
|
9
|
+
see the pre-merge checkout, with no MERGE_HEAD. post-merge runs natively at landing.
|
|
10
|
+
Hooks are trusted programs and can themselves change the checkout. Concurrency
|
|
11
|
+
protection detects HEAD/branch drift before landing; it is not a transaction.
|
|
12
|
+
|
|
13
|
+
bring-in.py --repo <checkout> --branch <branch> --target <rev> [--ff-only]
|
|
14
|
+
bring-in.py --self-test
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import argparse
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
import os
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
import re
|
|
23
|
+
import runpy
|
|
24
|
+
import shlex
|
|
25
|
+
import shutil
|
|
26
|
+
import subprocess
|
|
27
|
+
import sys
|
|
28
|
+
import tempfile
|
|
29
|
+
import unittest
|
|
30
|
+
from unittest.mock import patch
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Refused(Exception):
|
|
34
|
+
"""An explicit refusal, leaving the caller to fix the reported cause."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def diagnostic(data):
|
|
38
|
+
if data:
|
|
39
|
+
sys.stderr.write(data.decode("utf-8", errors="replace"))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class Git:
|
|
43
|
+
def __init__(self, repo):
|
|
44
|
+
self.repo = Path(repo).resolve()
|
|
45
|
+
|
|
46
|
+
def run(self, *args, data=None, check=True, env=None):
|
|
47
|
+
result = subprocess.run(["git", "-C", str(self.repo), *map(str, args)],
|
|
48
|
+
input=data, capture_output=True, env=env)
|
|
49
|
+
diagnostic(result.stderr)
|
|
50
|
+
if check and result.returncode:
|
|
51
|
+
command = next(str(arg) for i, arg in enumerate(args)
|
|
52
|
+
if arg != "-c" and not str(arg).startswith("--config-env=")
|
|
53
|
+
and (i == 0 or args[i - 1] != "-c"))
|
|
54
|
+
raise Refused(f"git {command} failed (exit {result.returncode}).")
|
|
55
|
+
return result
|
|
56
|
+
|
|
57
|
+
def text(self, *args):
|
|
58
|
+
return self.run(*args).stdout.decode("utf-8", errors="surrogateescape").removesuffix("\n")
|
|
59
|
+
|
|
60
|
+
def config(self, key, kind=None):
|
|
61
|
+
result = self.run("config", *(["--type=" + kind] if kind else []), "--get", key, check=False)
|
|
62
|
+
if result.returncode == 1:
|
|
63
|
+
return None
|
|
64
|
+
if result.returncode:
|
|
65
|
+
raise Refused(f"Cannot read {key}; unset it or run the merge yourself.")
|
|
66
|
+
return result.stdout.decode("utf-8", errors="surrogateescape").removesuffix("\n")
|
|
67
|
+
|
|
68
|
+
def path(self, name):
|
|
69
|
+
return self.repo / self.text("rev-parse", "--git-path", name)
|
|
70
|
+
|
|
71
|
+
def ancestor(self, old, new):
|
|
72
|
+
result = self.run("merge-base", "--is-ancestor", old, new, check=False)
|
|
73
|
+
if result.returncode not in (0, 1):
|
|
74
|
+
raise Refused("Cannot determine commit ancestry.")
|
|
75
|
+
return result.returncode == 0
|
|
76
|
+
|
|
77
|
+
def snapshot(self, branch):
|
|
78
|
+
head_ref = self.run("symbolic-ref", "-q", "HEAD", check=False).stdout
|
|
79
|
+
head = self.run("rev-parse", "--verify", "HEAD").stdout
|
|
80
|
+
tip = self.run("rev-parse", "--verify", "refs/heads/" + branch).stdout
|
|
81
|
+
# Staged entries, not raw index bytes: Git may refresh cached stat data without changing them.
|
|
82
|
+
return head_ref, head, tip, self.run("ls-files", "--stage", "-z").stdout
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def merge_tokens(value):
|
|
86
|
+
"""Git alias.c split_cmdline quoting (backslash escapes even inside double quotes).
|
|
87
|
+
|
|
88
|
+
shlex's POSIX mode retains some double-quoted backslashes that Git removes.
|
|
89
|
+
Git also retains empty arguments at the boundaries of whitespace.
|
|
90
|
+
"""
|
|
91
|
+
tokens, word, quote, i = [], "", "", 0
|
|
92
|
+
while i < len(value):
|
|
93
|
+
char = value[i]
|
|
94
|
+
if not quote and char in " \t\n\r\v\f":
|
|
95
|
+
tokens.append(word)
|
|
96
|
+
word = ""
|
|
97
|
+
while i + 1 < len(value) and value[i + 1] in " \t\n\r\v\f":
|
|
98
|
+
i += 1
|
|
99
|
+
elif not quote and char in "\"'":
|
|
100
|
+
quote = char
|
|
101
|
+
elif quote and char == quote:
|
|
102
|
+
quote = ""
|
|
103
|
+
else:
|
|
104
|
+
if char == "\\" and quote != "'":
|
|
105
|
+
i += 1
|
|
106
|
+
if i == len(value):
|
|
107
|
+
raise Refused("trailing backslash")
|
|
108
|
+
char = value[i]
|
|
109
|
+
word += char
|
|
110
|
+
i += 1
|
|
111
|
+
if quote:
|
|
112
|
+
raise Refused("unclosed quote")
|
|
113
|
+
return tokens + [word] if value else []
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@dataclass
|
|
117
|
+
class Policy:
|
|
118
|
+
ff: str = "true"
|
|
119
|
+
verify: bool = False
|
|
120
|
+
sign: str | None = None
|
|
121
|
+
log: list[str] = field(default_factory=list)
|
|
122
|
+
options: list[str] = field(default_factory=list)
|
|
123
|
+
option_source: str = "branch.<name>.mergeOptions"
|
|
124
|
+
strategies: list[tuple[str, str]] = field(default_factory=list)
|
|
125
|
+
errors: list[str] = field(default_factory=list)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def read_policy(git, branch, ff_only):
|
|
129
|
+
policy = Policy(option_source=f"branch.{branch}.mergeOptions")
|
|
130
|
+
ff = git.config("merge.ff")
|
|
131
|
+
policy.ff = "only" if ff == "only" else (git.config("merge.ff", "bool") or "true")
|
|
132
|
+
policy.verify = git.config("merge.verifySignatures", "bool") == "true"
|
|
133
|
+
policy.sign = "" if git.config("commit.gpgSign", "bool") == "true" else None
|
|
134
|
+
setting = policy.option_source
|
|
135
|
+
try:
|
|
136
|
+
tokens = merge_tokens(git.config(setting) or "")
|
|
137
|
+
except Refused as exc:
|
|
138
|
+
raise Refused(f"{setting}: {exc}; unset it or run the merge yourself.") from exc
|
|
139
|
+
strategies = []
|
|
140
|
+
irrelevant = {"--no-edit", "--commit", "-q", "--quiet", "-v", "--verbose", "--stat", "-n", "--no-stat",
|
|
141
|
+
"--summary", "--no-summary", "--progress", "--no-progress", "--autostash", "--no-autostash",
|
|
142
|
+
"--rerere-autoupdate", "--no-rerere-autoupdate"}
|
|
143
|
+
i = 0
|
|
144
|
+
while i < len(tokens):
|
|
145
|
+
token = tokens[i]
|
|
146
|
+
if token in ("--ff", "--no-ff", "--ff-only"):
|
|
147
|
+
policy.ff = {"--ff": "true", "--no-ff": "false", "--ff-only": "only"}[token]
|
|
148
|
+
elif token in ("--verify-signatures", "--no-verify-signatures"):
|
|
149
|
+
policy.verify = token == "--verify-signatures"
|
|
150
|
+
elif token == "--no-gpg-sign":
|
|
151
|
+
policy.sign = None
|
|
152
|
+
elif token.startswith("-S") or token == "--gpg-sign" or token.startswith("--gpg-sign="):
|
|
153
|
+
policy.sign = token[2:] if token.startswith("-S") else token.partition("=")[2]
|
|
154
|
+
elif token in ("--log", "--no-log") or re.fullmatch(r"--log=\d+", token):
|
|
155
|
+
policy.log = [token]
|
|
156
|
+
elif token in ("-s", "--strategy", "-X", "--strategy-option") or token.startswith(("-s", "-X", "--strategy=", "--strategy-option=")):
|
|
157
|
+
strategy = token.startswith(("-s", "--strategy=")) or token == "--strategy"
|
|
158
|
+
if token in ("-s", "--strategy", "-X", "--strategy-option"):
|
|
159
|
+
i += 1
|
|
160
|
+
if i == len(tokens):
|
|
161
|
+
policy.errors.append(f"{setting}: missing value for {token!r}")
|
|
162
|
+
break
|
|
163
|
+
value = tokens[i]
|
|
164
|
+
else:
|
|
165
|
+
value = token.partition("=")[2] if token.startswith("--") else token[2:]
|
|
166
|
+
if strategy:
|
|
167
|
+
strategies.append((setting, value))
|
|
168
|
+
else:
|
|
169
|
+
policy.options += ["-X", value]
|
|
170
|
+
elif token not in irrelevant:
|
|
171
|
+
policy.errors.append(f"{setting}: unsupported token {token!r}")
|
|
172
|
+
i += 1
|
|
173
|
+
# Explicit strategies replace pull.twohead, just as in git merge.
|
|
174
|
+
twohead = git.config("pull.twohead")
|
|
175
|
+
if not strategies and twohead is not None:
|
|
176
|
+
strategies = [("pull.twohead", item) for item in twohead.split(" ")]
|
|
177
|
+
policy.strategies = strategies
|
|
178
|
+
if ff_only:
|
|
179
|
+
policy.ff = "only"
|
|
180
|
+
return policy
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def verify_target(git, target):
|
|
184
|
+
# merge defaults to marginal trust; verify-commit otherwise defaults to undefined.
|
|
185
|
+
options = ["-c", "gpg.minTrustLevel=marginal"] if git.config("gpg.minTrustLevel") is None else []
|
|
186
|
+
if git.run(*options, "verify-commit", target, check=False).returncode:
|
|
187
|
+
raise Refused(f"Target {target} does not have a good signature with the required trust.")
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def merge_name(git, target, commit):
|
|
191
|
+
# Git builtin/merge.c merge_name uses the spelling supplied by the caller.
|
|
192
|
+
ref = git.text("rev-parse", "--symbolic-full-name", "--verify", "--end-of-options", target)
|
|
193
|
+
for prefix, kind in (("refs/heads/", "branch"), ("refs/remotes/", "remote-tracking branch"), ("refs/tags/", "tag")):
|
|
194
|
+
if ref.startswith(prefix):
|
|
195
|
+
return f"{commit}\t\t{kind} '{target}' of .\n".encode("utf-8", errors="surrogateescape")
|
|
196
|
+
if re.fullmatch(r"[0-9a-fA-F]{4,64}", target):
|
|
197
|
+
return f"{commit}\t\tcommit '{target}'\n".encode("ascii")
|
|
198
|
+
raise Refused(f"Cannot reproduce Git's merge message for target {target!r}; use a branch or commit ID, or run the merge yourself.")
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def parse_git_version(version):
|
|
202
|
+
match = re.match(r"git version (\d+)\.(\d+)(?:\.\d+)?\b", version)
|
|
203
|
+
if not match:
|
|
204
|
+
raise Refused(f"Cannot determine Git version from {version!r}; update Git or merge it yourself.")
|
|
205
|
+
return tuple(map(int, match.groups()))
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def check_git_version(version):
|
|
209
|
+
if parse_git_version(version) < (2, 31):
|
|
210
|
+
raise Refused(f"Bringing in a branch needs Git 2.31 or newer (found {version}). Update Git, or merge it yourself.")
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def check_preparation(policy, version):
|
|
214
|
+
if policy.errors:
|
|
215
|
+
raise Refused("; ".join(policy.errors) + "; unset the setting or run the merge yourself.")
|
|
216
|
+
number = parse_git_version(version)
|
|
217
|
+
if number < (2, 38):
|
|
218
|
+
raise Refused(f"Preparing a merge commit needs Git 2.38 or newer (found {version}). Update Git, or merge it yourself.")
|
|
219
|
+
if policy.options and number < (2, 43):
|
|
220
|
+
raise Refused(f"{policy.option_source}: -X needs Git 2.43 or newer (found {version}); update Git, or unset the setting.")
|
|
221
|
+
for source, strategy in policy.strategies:
|
|
222
|
+
if strategy not in ("ort", "recursive") or (strategy == "recursive" and number < (2, 50)):
|
|
223
|
+
raise Refused(f"{source}: unsupported strategy {strategy!r} with {version}; unset the setting or run the merge yourself.")
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def prepare(git, args, policy, head, target):
|
|
227
|
+
check_preparation(policy, git.text("--version"))
|
|
228
|
+
cleanup = git.config("commit.cleanup")
|
|
229
|
+
if cleanup not in (None, "default", "whitespace", "scissors", "strip", "verbatim"):
|
|
230
|
+
raise Refused(f"Unsupported commit.cleanup {cleanup!r}; unset it or run the merge yourself.")
|
|
231
|
+
name = merge_name(git, args.target, target)
|
|
232
|
+
if policy.verify:
|
|
233
|
+
verify_target(git, target)
|
|
234
|
+
merged = git.run("merge-tree", "--write-tree", "--name-only", "--no-messages", *policy.options, head, target, check=False)
|
|
235
|
+
if merged.returncode == 1:
|
|
236
|
+
paths = merged.stdout.decode("utf-8", errors="replace").splitlines()[1:]
|
|
237
|
+
directory = git.repo.parent / (git.repo.name + "-bring-in-resolution")
|
|
238
|
+
rerun = ["python3", str(Path(__file__).resolve()), "--repo", str(git.repo), "--branch", args.branch, "--target", "<resolved-commit>"]
|
|
239
|
+
if args.ff_only:
|
|
240
|
+
rerun.append("--ff-only")
|
|
241
|
+
raise Refused("Merge conflicts: " + ", ".join(paths) + "\nResolve outside this checkout (choose an absent directory):\n" +
|
|
242
|
+
shlex.join(["git", "-C", str(git.repo), "worktree", "add", "--detach", str(directory), head]) + "\n" +
|
|
243
|
+
shlex.join(["cd", str(directory)]) + "\n" + shlex.join(["git", "merge", args.target]) +
|
|
244
|
+
"\n# Resolve the conflicts, then:\ngit add -- <resolved-paths>\ngit commit\ngit rev-parse HEAD\n# Use that commit ID below:\n" + shlex.join(rerun))
|
|
245
|
+
if merged.returncode:
|
|
246
|
+
raise Refused(f"git merge-tree failed (exit {merged.returncode}).")
|
|
247
|
+
tree = merged.stdout.splitlines()[0].decode("ascii")
|
|
248
|
+
message = git.run("fmt-merge-msg", "--into-name", args.branch, *policy.log, data=name).stdout
|
|
249
|
+
# fmt-merge-msg always credits people; native merge --no-edit does not. Credits
|
|
250
|
+
# occur before the sole shortlog heading. Descriptions (which may contain comment
|
|
251
|
+
# lines of their own) occur after it and must remain byte-for-byte intact.
|
|
252
|
+
title, heading, log = message.partition(b"\n* ")
|
|
253
|
+
if heading:
|
|
254
|
+
comment = git.config("core.commentString") or git.config("core.commentChar") or "#"
|
|
255
|
+
prefix = ("#" if comment == "auto" else comment).encode("utf-8", errors="surrogateescape")
|
|
256
|
+
title = b"\n".join(line for line in title.split(b"\n")
|
|
257
|
+
if not line.startswith((prefix + b" By ", prefix + b" Via ")))
|
|
258
|
+
message = title + heading + log
|
|
259
|
+
temp_root = Path(tempfile.gettempdir()).resolve()
|
|
260
|
+
top = Path(git.text("rev-parse", "--show-toplevel")).resolve()
|
|
261
|
+
if temp_root == top or top in temp_root.parents:
|
|
262
|
+
raise Refused("TMPDIR is inside the checkout; set it to a directory outside the checkout and run again.")
|
|
263
|
+
with tempfile.TemporaryDirectory(prefix="devlyn-bring-in-", dir=temp_root) as directory:
|
|
264
|
+
path = Path(directory) / "message"
|
|
265
|
+
path.write_bytes(message[:-1] if message.endswith(b"\n") else message)
|
|
266
|
+
env = dict(os.environ, GIT_EDITOR=":")
|
|
267
|
+
git.run("hook", "run", "--ignore-missing", "prepare-commit-msg", "--", path, "merge", env=env)
|
|
268
|
+
git.run("hook", "run", "--ignore-missing", "commit-msg", "--", path, env=env)
|
|
269
|
+
# Native no-editor merge cleans once, after both message hooks.
|
|
270
|
+
if cleanup != "verbatim":
|
|
271
|
+
path.write_bytes(git.run("stripspace", *(["--strip-comments"] if cleanup == "strip" else []),
|
|
272
|
+
data=path.read_bytes()).stdout)
|
|
273
|
+
if not path.read_bytes():
|
|
274
|
+
raise Refused("Empty merge message; fix the message hook and run again.")
|
|
275
|
+
return git.text("commit-tree", tree, "-p", head, "-p", target, "-F", str(path),
|
|
276
|
+
*(["-S" + policy.sign] if policy.sign is not None else []))
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def bring_in(args):
|
|
280
|
+
git = Git(args.repo)
|
|
281
|
+
before = None
|
|
282
|
+
try:
|
|
283
|
+
check_git_version(git.text("--version"))
|
|
284
|
+
if git.text("rev-parse", "--is-inside-work-tree") != "true":
|
|
285
|
+
raise Refused(f"{git.repo} is not a work tree.")
|
|
286
|
+
git.repo = Path(git.text("rev-parse", "--show-toplevel")).resolve()
|
|
287
|
+
expected = "refs/heads/" + args.branch
|
|
288
|
+
if git.run("symbolic-ref", "-q", "HEAD", check=False).stdout.rstrip(b"\n") != os.fsencode(expected):
|
|
289
|
+
raise Refused(f"Expected checked-out branch {args.branch}; HEAD is detached or on another branch. Never switch to force bring-in.")
|
|
290
|
+
for name in ("MERGE_HEAD", "CHERRY_PICK_HEAD", "REVERT_HEAD", "rebase-merge", "rebase-apply"):
|
|
291
|
+
if git.path(name).exists():
|
|
292
|
+
raise Refused(f"{name} exists; finish or abort the in-progress operation before bring-in.")
|
|
293
|
+
head = git.text("rev-parse", "--verify", "HEAD")
|
|
294
|
+
target = git.text("rev-parse", "--verify", "--end-of-options", args.target + "^{commit}")
|
|
295
|
+
before = git.snapshot(args.branch)
|
|
296
|
+
if target == head or git.ancestor(target, head):
|
|
297
|
+
print(f"{args.branch} is already up to date at {head}.")
|
|
298
|
+
return 0
|
|
299
|
+
policy = read_policy(git, args.branch, args.ff_only)
|
|
300
|
+
if policy.ff != "only" and git.text("cat-file", "-t", args.target) == "tag":
|
|
301
|
+
raise Refused(f"Annotated-tag target {args.target!r} needs a mergetag; run the merge yourself.")
|
|
302
|
+
fast_forward = git.ancestor(head, target)
|
|
303
|
+
if policy.ff == "only" and not fast_forward:
|
|
304
|
+
raise Refused(f"{args.branch} has commits of its own; retain and report the delivered result, or run the merge yourself.")
|
|
305
|
+
prepared = not fast_forward or policy.ff == "false"
|
|
306
|
+
commit = prepare(git, args, policy, head, target) if prepared else target
|
|
307
|
+
if (git.run("symbolic-ref", "-q", "HEAD", check=False).stdout.rstrip(b"\n") != os.fsencode(expected)
|
|
308
|
+
or git.text("rev-parse", "HEAD") != head):
|
|
309
|
+
raise Refused(f"{args.branch} moved while preparing; run again.")
|
|
310
|
+
verify = "--verify-signatures" if policy.verify and not prepared else "--no-verify-signatures"
|
|
311
|
+
git.run(f"--config-env=branch.{args.branch}.mergeOptions=DEVLYN_BRING_IN_EMPTY",
|
|
312
|
+
"merge", "--ff-only", "--no-overwrite-ignore", "--no-autostash", "-s", "ort", verify, commit,
|
|
313
|
+
env=dict(os.environ, DEVLYN_BRING_IN_EMPTY=""))
|
|
314
|
+
if (git.text("symbolic-ref", "-q", "HEAD") != expected or git.text("rev-parse", "HEAD") != commit
|
|
315
|
+
or git.text("rev-parse", "--verify", expected) != commit):
|
|
316
|
+
raise Refused("Landing returned success but the branch or HEAD differs from the prepared commit; inspect the checkout before retrying.")
|
|
317
|
+
print(f"{args.branch} {'merged' if prepared else 'fast-forwarded'} to {commit}.")
|
|
318
|
+
return 0
|
|
319
|
+
except (Refused, OSError) as exc:
|
|
320
|
+
print(str(exc), file=sys.stderr)
|
|
321
|
+
if before is not None:
|
|
322
|
+
try:
|
|
323
|
+
unchanged = (git.snapshot(args.branch) == before and before[:3] ==
|
|
324
|
+
(os.fsencode(expected) + b"\n", head.encode("ascii") + b"\n", head.encode("ascii") + b"\n"))
|
|
325
|
+
except (Refused, OSError) as check_error:
|
|
326
|
+
print(f"Cannot verify unchanged state: {check_error}", file=sys.stderr)
|
|
327
|
+
unchanged = False
|
|
328
|
+
if unchanged:
|
|
329
|
+
print(f"Not brought in: {args.branch} is still at {head} and its staged entries are unchanged. Fix what Git reported above "
|
|
330
|
+
"(move files that are in the way aside), then run the same command again.", file=sys.stderr)
|
|
331
|
+
else:
|
|
332
|
+
print("HEAD, branch or index changed; inspect the checkout before retrying.", file=sys.stderr)
|
|
333
|
+
return 1
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def main():
|
|
337
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
338
|
+
parser.add_argument("--repo", type=Path)
|
|
339
|
+
parser.add_argument("--branch")
|
|
340
|
+
parser.add_argument("--target")
|
|
341
|
+
parser.add_argument("--ff-only", action="store_true")
|
|
342
|
+
parser.add_argument("--self-test", action="store_true")
|
|
343
|
+
args = parser.parse_args()
|
|
344
|
+
if args.self_test:
|
|
345
|
+
suite = unittest.defaultTestLoader.loadTestsFromTestCase(BringInTests)
|
|
346
|
+
return 0 if unittest.TextTestRunner(verbosity=2).run(suite).wasSuccessful() else 1
|
|
347
|
+
if not all((args.repo, args.branch, args.target)):
|
|
348
|
+
parser.error("--repo, --branch and --target are required")
|
|
349
|
+
return bring_in(args)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
class BringInTests(unittest.TestCase):
|
|
353
|
+
"""Prediction: preparation never writes the anchor; protected landing refuses all collisions.
|
|
354
|
+
|
|
355
|
+
Before: true merges ignored --no-overwrite-ignore, and configuration could turn
|
|
356
|
+
even --ff-only into a merge that overwrote files or discarded delivered content.
|
|
357
|
+
"""
|
|
358
|
+
def setUp(self):
|
|
359
|
+
self.temp = tempfile.TemporaryDirectory(prefix="bring in ✓ 한글 ")
|
|
360
|
+
self.addCleanup(self.temp.cleanup)
|
|
361
|
+
self.root = Path(self.temp.name).resolve()
|
|
362
|
+
self.repo = self.root / "owner checkout"
|
|
363
|
+
self.env = {key: value for key, value in os.environ.items()
|
|
364
|
+
if not key.startswith("GIT_")}
|
|
365
|
+
self.env.update(GIT_CONFIG_NOSYSTEM="1", GIT_CONFIG_GLOBAL=str(self.root / "no-global"),
|
|
366
|
+
GIT_AUTHOR_NAME="Fixture", GIT_AUTHOR_EMAIL="fixture@example.invalid",
|
|
367
|
+
GIT_COMMITTER_NAME="Fixture", GIT_COMMITTER_EMAIL="fixture@example.invalid",
|
|
368
|
+
LC_ALL="C", GIT_EDITOR=":", GIT_MERGE_AUTOEDIT="no")
|
|
369
|
+
self.g("init", "-q", "--initial-branch=main", str(self.repo), repo=self.root)
|
|
370
|
+
self.commit("base", "base\n")
|
|
371
|
+
self.base = self.g("rev-parse", "HEAD").strip()
|
|
372
|
+
self.g("checkout", "-qb", "topic")
|
|
373
|
+
self.commit("delivered", "delivered\n")
|
|
374
|
+
self.target = self.g("rev-parse", "HEAD").strip()
|
|
375
|
+
self.g("checkout", "-q", "main")
|
|
376
|
+
|
|
377
|
+
def g(self, *args, repo=None):
|
|
378
|
+
result = subprocess.run(["git", "-C", str(repo or self.repo), *map(str, args)], env=self.env, capture_output=True)
|
|
379
|
+
self.assertEqual(result.returncode, 0, result.stderr.decode("utf-8", errors="replace"))
|
|
380
|
+
return result.stdout
|
|
381
|
+
|
|
382
|
+
def commit(self, name, content):
|
|
383
|
+
path = self.repo / name
|
|
384
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
385
|
+
path.write_bytes(content.encode("utf-8"))
|
|
386
|
+
self.g("add", "--", name)
|
|
387
|
+
self.g("commit", "-qm", "add " + name)
|
|
388
|
+
|
|
389
|
+
def diverge(self):
|
|
390
|
+
self.commit("owner", "owner\n")
|
|
391
|
+
return self.g("rev-parse", "HEAD").strip()
|
|
392
|
+
|
|
393
|
+
def state(self):
|
|
394
|
+
return (self.g("rev-parse", "HEAD"), self.g("symbolic-ref", "HEAD"),
|
|
395
|
+
self.g("rev-parse", "main"), self.g("ls-files", "--stage", "-z"))
|
|
396
|
+
|
|
397
|
+
def cli(self, *extra, target="topic", branch="main", code=0):
|
|
398
|
+
result = subprocess.run([sys.executable, str(Path(__file__).resolve()), "--repo", str(self.repo),
|
|
399
|
+
"--branch", branch, "--target", target, *extra], env=self.env, capture_output=True)
|
|
400
|
+
self.assertEqual(result.returncode, code, (result.stdout + result.stderr).decode("utf-8", errors="replace"))
|
|
401
|
+
if code == 0:
|
|
402
|
+
self.assertEqual(len(result.stdout.splitlines()), 1)
|
|
403
|
+
return result
|
|
404
|
+
|
|
405
|
+
def refused(self, *extra, target="topic", phrase=b"Not brought in"):
|
|
406
|
+
before = self.state()
|
|
407
|
+
result = self.cli(*extra, target=target, code=1)
|
|
408
|
+
self.assertEqual(self.state(), before)
|
|
409
|
+
self.assertIn(phrase, result.stderr)
|
|
410
|
+
self.assertFalse((self.repo / ".git/MERGE_HEAD").exists())
|
|
411
|
+
return result
|
|
412
|
+
|
|
413
|
+
def hook(self, name, body):
|
|
414
|
+
path = self.repo / ".git/hooks" / name
|
|
415
|
+
path.write_bytes(("#!/bin/sh\n" + body).encode("utf-8"))
|
|
416
|
+
path.chmod(0o755)
|
|
417
|
+
|
|
418
|
+
def test_fast_forward_and_paths_with_spaces(self):
|
|
419
|
+
self.cli()
|
|
420
|
+
self.assertEqual(self.g("rev-parse", "HEAD").strip(), self.target)
|
|
421
|
+
self.assertEqual((self.repo / "delivered").read_bytes(), b"delivered\n")
|
|
422
|
+
|
|
423
|
+
def test_equals_branch_fast_forwards_despite_ours(self):
|
|
424
|
+
# Prediction: '=' in a branch name cannot keep -s ours active at landing.
|
|
425
|
+
self.g("branch", "-m", "feature=x")
|
|
426
|
+
self.g("config", "branch.feature=x.mergeOptions", "-s ours")
|
|
427
|
+
self.cli(branch="feature=x")
|
|
428
|
+
self.assertEqual(self.g("rev-parse", "HEAD").strip(), self.target)
|
|
429
|
+
self.assertEqual((self.repo / "delivered").read_bytes(), b"delivered\n")
|
|
430
|
+
self.assertEqual(self.g("show", "-s", "--format=%P", "HEAD").strip(), self.base)
|
|
431
|
+
|
|
432
|
+
def test_refusal_reports_only_verified_state_after_hook_changes_file(self):
|
|
433
|
+
# Prediction: unchanged refs/index do not imply unchanged working files.
|
|
434
|
+
self.diverge()
|
|
435
|
+
self.hook("commit-msg", 'printf "hook bytes\\n" > base\nexit 1\n')
|
|
436
|
+
result = self.refused(phrase=b"Not brought in: main is still at ")
|
|
437
|
+
self.assertIn(b"and its staged entries are unchanged. Fix what Git reported above", result.stderr)
|
|
438
|
+
self.assertEqual((self.repo / "base").read_bytes(), b"hook bytes\n")
|
|
439
|
+
|
|
440
|
+
def test_fast_forward_collisions(self):
|
|
441
|
+
for content in (b"owner bytes\n", b"delivered\n"):
|
|
442
|
+
with self.subTest(content=content):
|
|
443
|
+
(self.repo / ".git/info/exclude").write_bytes(b"delivered\n")
|
|
444
|
+
(self.repo / "delivered").write_bytes(content)
|
|
445
|
+
result = self.refused()
|
|
446
|
+
self.assertIn(b"git merge failed", result.stderr)
|
|
447
|
+
self.assertEqual((self.repo / "delivered").read_bytes(), content)
|
|
448
|
+
|
|
449
|
+
def test_diverged_collisions(self):
|
|
450
|
+
self.diverge()
|
|
451
|
+
for content in (b"owner bytes\n", b"delivered\n"):
|
|
452
|
+
with self.subTest(content=content):
|
|
453
|
+
(self.repo / ".git/info/exclude").write_bytes(b"delivered\n")
|
|
454
|
+
(self.repo / "delivered").write_bytes(content)
|
|
455
|
+
self.refused()
|
|
456
|
+
self.assertEqual((self.repo / "delivered").read_bytes(), content)
|
|
457
|
+
|
|
458
|
+
def test_directory_to_file_collision(self):
|
|
459
|
+
self.commit("d/tracked", "original tracked child\n")
|
|
460
|
+
self.g("branch", "-f", "topic", "HEAD")
|
|
461
|
+
self.g("checkout", "-q", "topic")
|
|
462
|
+
self.g("rm", "d/tracked")
|
|
463
|
+
self.commit("d", "delivered file\n")
|
|
464
|
+
self.g("checkout", "-q", "main")
|
|
465
|
+
self.diverge()
|
|
466
|
+
(self.repo / ".git/info/exclude").write_bytes(b"d/secret\n")
|
|
467
|
+
(self.repo / "d/secret").write_bytes(b"owner secret\x00")
|
|
468
|
+
self.refused()
|
|
469
|
+
self.assertEqual((self.repo / "d/secret").read_bytes(), b"owner secret\x00")
|
|
470
|
+
self.assertEqual((self.repo / "d/tracked").read_bytes(), b"original tracked child\n")
|
|
471
|
+
|
|
472
|
+
def native_message(self, target, *options):
|
|
473
|
+
with tempfile.TemporaryDirectory(prefix="native copy ", dir=self.root) as directory:
|
|
474
|
+
clone = Path(directory) / "repo"
|
|
475
|
+
shutil.copytree(self.repo, clone)
|
|
476
|
+
self.g("merge", "--no-edit", *options, target, repo=clone)
|
|
477
|
+
return self.g("cat-file", "commit", "HEAD", repo=clone).split(b"\n\n", 1)[1]
|
|
478
|
+
|
|
479
|
+
def message(self):
|
|
480
|
+
return self.g("cat-file", "commit", "HEAD").split(b"\n\n", 1)[1]
|
|
481
|
+
|
|
482
|
+
def test_merge_message_and_parents_equal_native(self):
|
|
483
|
+
head = self.diverge()
|
|
484
|
+
self.g("update-ref", "refs/remotes/origin/topic", self.target.decode())
|
|
485
|
+
self.g("tag", "lightweight", "topic")
|
|
486
|
+
for target in ("topic", "refs/heads/topic", "origin/topic", "lightweight", self.target.decode()):
|
|
487
|
+
for log in ("false", "true"):
|
|
488
|
+
with self.subTest(target=target, log=log):
|
|
489
|
+
self.g("config", "merge.log", log)
|
|
490
|
+
expected = self.native_message(target)
|
|
491
|
+
self.cli(target=target)
|
|
492
|
+
self.assertEqual(self.g("show", "-s", "--format=%P", "HEAD").strip(), head + b" " + self.target)
|
|
493
|
+
self.assertEqual(self.message(), expected)
|
|
494
|
+
self.g("reset", "--hard", head.decode())
|
|
495
|
+
|
|
496
|
+
def test_merge_log_with_another_author_and_log_options(self):
|
|
497
|
+
# Prediction: fmt-merge-msg and native no-edit merge agree even when the author differs.
|
|
498
|
+
# Before: equal-author fixtures alone cannot expose automatic author credit or log-option differences.
|
|
499
|
+
self.g("checkout", "-q", "topic")
|
|
500
|
+
self.g("commit", "--amend", "--author=Contributor <contributor@example.invalid>", "-m", "contributed work")
|
|
501
|
+
self.g("checkout", "-q", "main")
|
|
502
|
+
head = self.diverge()
|
|
503
|
+
self.g("config", "merge.log", "true")
|
|
504
|
+
self.g("config", "merge.branchdesc", "true")
|
|
505
|
+
self.g("config", "branch.topic.description", "# By a description, not a credit\nkeep this\n")
|
|
506
|
+
self.g("config", "core.commentChar", ";")
|
|
507
|
+
for options in ("--no-edit", "--no-log", "--log=1", "--no-log --log"):
|
|
508
|
+
with self.subTest(options=options):
|
|
509
|
+
self.g("config", "branch.main.mergeOptions", options)
|
|
510
|
+
expected = self.native_message("topic")
|
|
511
|
+
self.cli()
|
|
512
|
+
self.assertEqual(self.message(), expected)
|
|
513
|
+
self.g("reset", "--hard", head.decode())
|
|
514
|
+
|
|
515
|
+
def test_conflict(self):
|
|
516
|
+
self.g("checkout", "-q", "topic")
|
|
517
|
+
self.commit("base", "topic change\n")
|
|
518
|
+
self.g("checkout", "-q", "main")
|
|
519
|
+
self.commit("base", "owner change\n")
|
|
520
|
+
nested = self.repo / "nested"
|
|
521
|
+
nested.mkdir()
|
|
522
|
+
result = self.refused("--repo", str(nested), phrase=b"Merge conflicts: base")
|
|
523
|
+
self.assertIn(os.fsencode(self.repo.parent / (self.repo.name + "-bring-in-resolution")), result.stderr)
|
|
524
|
+
self.assertIn(b"worktree add --detach", result.stderr)
|
|
525
|
+
self.assertIn(b"--target '<resolved-commit>'", result.stderr)
|
|
526
|
+
|
|
527
|
+
def test_up_to_date(self):
|
|
528
|
+
before = self.state()
|
|
529
|
+
self.assertIn(b"already up to date", self.cli(target="main").stdout)
|
|
530
|
+
self.assertEqual(self.state(), before)
|
|
531
|
+
self.diverge()
|
|
532
|
+
before = self.state()
|
|
533
|
+
self.assertIn(b"already up to date", self.cli(target=self.base.decode()).stdout)
|
|
534
|
+
self.assertEqual(self.state(), before)
|
|
535
|
+
|
|
536
|
+
def test_detached_and_other_branch(self):
|
|
537
|
+
self.cli(branch="other", code=1)
|
|
538
|
+
self.assertEqual(self.g("symbolic-ref", "HEAD"), b"refs/heads/main\n")
|
|
539
|
+
self.g("checkout", "-q", "--detach")
|
|
540
|
+
self.assertIn(b"detached", self.cli(code=1).stderr)
|
|
541
|
+
self.assertEqual(self.g("rev-parse", "HEAD").strip(), self.base)
|
|
542
|
+
|
|
543
|
+
def test_in_progress_operations(self):
|
|
544
|
+
for name in ("MERGE_HEAD", "CHERRY_PICK_HEAD", "REVERT_HEAD", "rebase-merge", "rebase-apply"):
|
|
545
|
+
with self.subTest(name=name):
|
|
546
|
+
path = self.repo / ".git" / name
|
|
547
|
+
if name.startswith("rebase-"):
|
|
548
|
+
path.mkdir()
|
|
549
|
+
else:
|
|
550
|
+
path.write_bytes(self.target + b"\n")
|
|
551
|
+
before = self.state()
|
|
552
|
+
self.assertIn(name.encode(), self.cli(code=1).stderr)
|
|
553
|
+
self.assertEqual(self.state(), before)
|
|
554
|
+
path.rmdir() if path.is_dir() else path.unlink()
|
|
555
|
+
|
|
556
|
+
def test_unsupported_options_and_strategy(self):
|
|
557
|
+
self.diverge()
|
|
558
|
+
for value in ("-s ours", "--squash", "--overwrite-ignore", "--no-commit", "--edit", "--signoff", "--cleanup=verbatim", "-s subtree"):
|
|
559
|
+
with self.subTest(value=value):
|
|
560
|
+
self.g("config", "branch.main.mergeOptions", value)
|
|
561
|
+
self.refused(phrase=b"branch.main.mergeOptions")
|
|
562
|
+
|
|
563
|
+
def test_strategy_option_and_quoting(self):
|
|
564
|
+
# Prediction: -X is honored from 2.43 onward and explicitly refused on older Git.
|
|
565
|
+
self.g("checkout", "-q", "topic")
|
|
566
|
+
self.commit("base", "base \n")
|
|
567
|
+
self.g("checkout", "-q", "main")
|
|
568
|
+
self.commit("base", "owner change\n")
|
|
569
|
+
self.g("config", "branch.main.mergeOptions", '-s ort -X "ignore-space-change"')
|
|
570
|
+
self.assertEqual(merge_tokens(r'''-X "a\b" 'c\d' a\ b'''), ["-X", "ab", "c\\d", "a b"])
|
|
571
|
+
if parse_git_version(self.g("--version").decode()) < (2, 43):
|
|
572
|
+
self.refused(phrase=b"branch.main.mergeOptions: -X needs Git 2.43 or newer")
|
|
573
|
+
return
|
|
574
|
+
expected = self.native_message("topic")
|
|
575
|
+
self.cli()
|
|
576
|
+
self.assertEqual((self.repo / "base").read_bytes(), b"owner change\n")
|
|
577
|
+
self.assertEqual(self.message(), expected)
|
|
578
|
+
|
|
579
|
+
def test_fast_forward_cannot_be_hijacked(self):
|
|
580
|
+
for setting, value in (("pull.twohead", "ours"), ("branch.main.mergeOptions", "-s ours"),
|
|
581
|
+
("branch.main.mergeOptions", "-s subtree"), ("branch.main.mergeOptions", "--squash")):
|
|
582
|
+
with self.subTest(setting=setting, value=value):
|
|
583
|
+
self.g("config", setting, value)
|
|
584
|
+
self.cli()
|
|
585
|
+
self.assertEqual(self.g("rev-parse", "HEAD").strip(), self.target)
|
|
586
|
+
self.g("reset", "--hard", self.base.decode())
|
|
587
|
+
(self.repo / ".git/info/exclude").write_bytes(b"delivered\n")
|
|
588
|
+
(self.repo / "delivered").write_bytes(b"owner\n")
|
|
589
|
+
self.refused()
|
|
590
|
+
self.assertEqual((self.repo / "delivered").read_bytes(), b"owner\n")
|
|
591
|
+
(self.repo / "delivered").unlink()
|
|
592
|
+
self.g("config", "--unset", setting)
|
|
593
|
+
self.diverge()
|
|
594
|
+
self.g("config", "pull.twohead", "ours")
|
|
595
|
+
self.refused(phrase=b"pull.twohead")
|
|
596
|
+
self.g("config", "branch.main.mergeOptions", "-s ort")
|
|
597
|
+
self.cli() # The explicit supported strategy overrides pull.twohead.
|
|
598
|
+
|
|
599
|
+
def test_ff_policy(self):
|
|
600
|
+
self.g("config", "merge.ff", "false")
|
|
601
|
+
expected = self.native_message("topic")
|
|
602
|
+
self.cli()
|
|
603
|
+
self.assertEqual(self.g("show", "-s", "--format=%P", "HEAD").strip(), self.base + b" " + self.target)
|
|
604
|
+
self.assertEqual(self.message(), expected)
|
|
605
|
+
self.g("reset", "--hard", self.base.decode())
|
|
606
|
+
self.cli("--ff-only")
|
|
607
|
+
self.assertEqual(self.g("rev-parse", "HEAD").strip(), self.target)
|
|
608
|
+
self.g("reset", "--hard", self.base.decode())
|
|
609
|
+
self.diverge()
|
|
610
|
+
self.g("config", "merge.ff", "only")
|
|
611
|
+
self.refused(phrase=b"main has commits of its own")
|
|
612
|
+
self.g("config", "merge.ff", "true")
|
|
613
|
+
self.refused("--ff-only", phrase=b"main has commits of its own")
|
|
614
|
+
self.g("config", "branch.main.mergeOptions", "--no-ff --ff --ff-only")
|
|
615
|
+
self.refused(phrase=b"main has commits of its own")
|
|
616
|
+
|
|
617
|
+
def test_signing_failure(self):
|
|
618
|
+
self.diverge()
|
|
619
|
+
program = self.root / "fail-sign"
|
|
620
|
+
program.write_bytes(b"#!/bin/sh\nexit 1\n")
|
|
621
|
+
program.chmod(0o755)
|
|
622
|
+
self.g("config", "commit.gpgSign", "true")
|
|
623
|
+
self.g("config", "gpg.program", str(program))
|
|
624
|
+
self.refused()
|
|
625
|
+
self.g("config", "branch.main.mergeOptions", "--no-gpg-sign")
|
|
626
|
+
self.cli()
|
|
627
|
+
|
|
628
|
+
def test_unsigned_target_verification(self):
|
|
629
|
+
# Prediction: an unsigned divergent target refuses even with signature display enabled.
|
|
630
|
+
self.g("config", "merge.verifySignatures", "true")
|
|
631
|
+
self.refused()
|
|
632
|
+
self.diverge()
|
|
633
|
+
for show in ("false", "true"):
|
|
634
|
+
self.g("config", "log.showSignature", show)
|
|
635
|
+
result = self.refused(phrase=self.target)
|
|
636
|
+
self.assertNotIn(b"Traceback", result.stderr)
|
|
637
|
+
self.g("config", "branch.main.mergeOptions", "--no-verify-signatures")
|
|
638
|
+
self.cli()
|
|
639
|
+
|
|
640
|
+
def test_verification_uses_native_verifier_and_trust_default(self):
|
|
641
|
+
# Prediction: Git verifies signatures with marginal default and configured case intact.
|
|
642
|
+
git = Git(self.repo)
|
|
643
|
+
for minimum, options in ((None, ("-c", "gpg.minTrustLevel=marginal")), ("FULLY", ())):
|
|
644
|
+
with self.subTest(minimum=minimum), patch.object(git, "config", return_value=minimum), \
|
|
645
|
+
patch.object(git, "run", return_value=subprocess.CompletedProcess([], 0, b"", b"")) as run:
|
|
646
|
+
verify_target(git, "target-id")
|
|
647
|
+
run.assert_called_once_with(*options, "verify-commit", "target-id", check=False)
|
|
648
|
+
run.return_value.returncode = 1
|
|
649
|
+
with self.assertRaisesRegex(Refused, "target-id"):
|
|
650
|
+
verify_target(git, "target-id")
|
|
651
|
+
|
|
652
|
+
def test_cleanup_modes_match_native_after_commit_msg(self):
|
|
653
|
+
# Prediction: cleanup happens once after commit-msg, honoring commit.cleanup and commentChar.
|
|
654
|
+
head = self.diverge()
|
|
655
|
+
for mode in ("default", "whitespace", "scissors", "strip", "verbatim"):
|
|
656
|
+
for comment in ("#", ";"):
|
|
657
|
+
with self.subTest(mode=mode, comment=comment):
|
|
658
|
+
self.g("reset", "--hard", head.decode())
|
|
659
|
+
self.g("config", "commit.cleanup", mode)
|
|
660
|
+
self.g("config", "core.commentChar", comment)
|
|
661
|
+
self.hook("commit-msg", f'printf "\\n{comment} comment \\n\\n" >> "$1"\n')
|
|
662
|
+
expected = self.native_message("topic")
|
|
663
|
+
self.cli()
|
|
664
|
+
self.assertEqual(self.message(), expected)
|
|
665
|
+
if mode == "strip":
|
|
666
|
+
self.assertNotIn(b"comment", self.message())
|
|
667
|
+
|
|
668
|
+
def test_commit_msg_sees_prepare_hook_whitespace_and_refuses_like_native(self):
|
|
669
|
+
# Prediction: commit-msg sees trailing whitespace from prepare-commit-msg before cleanup.
|
|
670
|
+
self.diverge()
|
|
671
|
+
self.hook("prepare-commit-msg", 'printf "\\ntrailing \\n" >> "$1"\n')
|
|
672
|
+
self.hook("commit-msg", 'if grep -q "[[:blank:]]$" "$1"; then exit 1; fi\n')
|
|
673
|
+
with tempfile.TemporaryDirectory(prefix="native hook copy ", dir=self.root) as directory:
|
|
674
|
+
clone = Path(directory) / "repo"
|
|
675
|
+
shutil.copytree(self.repo, clone)
|
|
676
|
+
result = subprocess.run(["git", "-C", str(clone), "merge", "--no-edit", "topic"],
|
|
677
|
+
env=self.env, capture_output=True)
|
|
678
|
+
self.assertNotEqual(result.returncode, 0)
|
|
679
|
+
self.refused()
|
|
680
|
+
|
|
681
|
+
def test_invalid_cleanup_refuses_before_hooks(self):
|
|
682
|
+
# Prediction: an invalid cleanup mode refuses without invoking any message hook.
|
|
683
|
+
self.diverge()
|
|
684
|
+
self.g("config", "commit.cleanup", "invalid")
|
|
685
|
+
self.hook("prepare-commit-msg", "touch hook-ran\n")
|
|
686
|
+
self.refused(phrase=b"commit.cleanup")
|
|
687
|
+
self.assertFalse((self.repo / "hook-ran").exists())
|
|
688
|
+
|
|
689
|
+
def test_hooks_and_native_cleanup(self):
|
|
690
|
+
self.diverge()
|
|
691
|
+
self.hook("commit-msg", "exit 1\n")
|
|
692
|
+
self.refused()
|
|
693
|
+
self.hook("prepare-commit-msg", 'printf "\\n# kept comment \\n\\n" >> "$1"\n')
|
|
694
|
+
self.hook("commit-msg", 'printf "\\nHook edit \\n\\n" >> "$1"\n')
|
|
695
|
+
expected = self.native_message("topic")
|
|
696
|
+
self.hook("pre-merge-commit", "exit 1\n") # Its incompatible contract must not run.
|
|
697
|
+
self.hook("post-merge", 'printf "%s" "$1" > post-merge-ran\n')
|
|
698
|
+
self.cli()
|
|
699
|
+
self.assertEqual(self.message(), expected)
|
|
700
|
+
self.assertIn(b"Hook edit\n", self.message())
|
|
701
|
+
self.assertEqual((self.repo / "post-merge-ran").read_bytes(), b"0")
|
|
702
|
+
|
|
703
|
+
def test_hook_drift_is_not_rolled_back(self):
|
|
704
|
+
self.diverge()
|
|
705
|
+
self.hook("prepare-commit-msg", 'if test "$2" = merge; then git -c core.hooksPath=/dev/null commit --allow-empty -qm "hook commit"; fi\n')
|
|
706
|
+
result = self.cli(code=1)
|
|
707
|
+
self.assertIn(b"moved while preparing", result.stderr)
|
|
708
|
+
self.assertIn(b"HEAD, branch or index changed; inspect the checkout before retrying.", result.stderr)
|
|
709
|
+
self.assertEqual(self.g("show", "-s", "--format=%s", "HEAD"), b"hook commit\n")
|
|
710
|
+
self.assertFalse((self.repo / "delivered").exists())
|
|
711
|
+
|
|
712
|
+
def test_annotated_tag_and_revision_expression_refused(self):
|
|
713
|
+
self.diverge()
|
|
714
|
+
self.g("tag", "-am", "tag message", "annotated", "topic")
|
|
715
|
+
self.refused(target="annotated", phrase=b"mergetag")
|
|
716
|
+
self.refused(target="topic~0", phrase=b"Cannot reproduce Git's merge message")
|
|
717
|
+
|
|
718
|
+
def test_annotated_tag_fast_forward_requires_ff_only(self):
|
|
719
|
+
# Prediction: an annotated tag needs a mergetag unless effective ff mode is only.
|
|
720
|
+
self.g("tag", "-am", "tag message", "annotated", "topic")
|
|
721
|
+
self.refused(target="annotated", phrase=b"mergetag")
|
|
722
|
+
for options in (("--ff-only",), ()):
|
|
723
|
+
with self.subTest(options=options):
|
|
724
|
+
if not options:
|
|
725
|
+
self.g("config", "merge.ff", "only")
|
|
726
|
+
expected = self.native_message("annotated", *options)
|
|
727
|
+
self.cli(*options, target="annotated")
|
|
728
|
+
self.assertEqual(self.g("rev-parse", "HEAD").strip(), self.target)
|
|
729
|
+
self.assertEqual(self.message(), expected)
|
|
730
|
+
self.assertEqual((self.repo / "delivered").read_bytes(), b"delivered\n")
|
|
731
|
+
self.g("reset", "--hard", self.base.decode())
|
|
732
|
+
|
|
733
|
+
def test_recursive_preparation_requires_git_250(self):
|
|
734
|
+
# Prediction: recursive refuses before 2.50, when it became an alias for ort.
|
|
735
|
+
for source in ("branch.main.mergeOptions", "pull.twohead"):
|
|
736
|
+
self.g("config", source, "-s recursive" if source.startswith("branch.") else "recursive")
|
|
737
|
+
policy = read_policy(Git(self.repo), "main", False)
|
|
738
|
+
for version in ("git version 2.43.0", "git version 2.49.9"):
|
|
739
|
+
with self.subTest(source=source, version=version):
|
|
740
|
+
with self.assertRaises(Refused) as refused:
|
|
741
|
+
check_preparation(policy, version)
|
|
742
|
+
self.assertIn(source, str(refused.exception))
|
|
743
|
+
self.assertIn(version, str(refused.exception))
|
|
744
|
+
check_preparation(policy, "git version 2.50.0")
|
|
745
|
+
self.g("config", "--unset", source)
|
|
746
|
+
|
|
747
|
+
def test_preparation_feature_floors(self):
|
|
748
|
+
# Prediction: plain preparation needs 2.38; only configured -X raises that to 2.43.
|
|
749
|
+
for ff in ("true", "false"):
|
|
750
|
+
with self.subTest(ff=ff):
|
|
751
|
+
with self.assertRaisesRegex(Refused, r"Preparing a merge commit needs Git 2.38 or newer \(found git version 2.37.0\)"):
|
|
752
|
+
check_preparation(Policy(ff=ff), "git version 2.37.0")
|
|
753
|
+
check_preparation(Policy(), "git version 2.38.0")
|
|
754
|
+
check_preparation(Policy(), "git version 2.39.5")
|
|
755
|
+
policy = Policy(options=["-X", "ours"], option_source="branch.main.mergeOptions")
|
|
756
|
+
with self.assertRaisesRegex(Refused, r"branch.main.mergeOptions: -X needs Git 2.43 or newer \(found git version 2.39.5\)"):
|
|
757
|
+
check_preparation(policy, "git version 2.39.5")
|
|
758
|
+
check_preparation(policy, "git version 2.43.0")
|
|
759
|
+
|
|
760
|
+
def test_rename_settings_match_native_merge(self):
|
|
761
|
+
# Prediction: on the same Git, both fixtures conflict like native merge or land its tree.
|
|
762
|
+
for fixture in ("file", "directory"):
|
|
763
|
+
self.g("reset", "--hard", self.base.decode())
|
|
764
|
+
self.commit("original/file", "original content\n")
|
|
765
|
+
self.g("branch", "-f", "topic", "HEAD")
|
|
766
|
+
self.g("checkout", "-q", "topic")
|
|
767
|
+
self.g("mv", "original/file" if fixture == "file" else "original", "renamed")
|
|
768
|
+
self.g("commit", "-qm", "rename " + fixture)
|
|
769
|
+
self.g("checkout", "-q", "main")
|
|
770
|
+
self.commit("original/file" if fixture == "file" else "original/new", "owner content\n")
|
|
771
|
+
head = self.g("rev-parse", "HEAD").strip().decode()
|
|
772
|
+
for setting in ("merge.renames", "merge.directoryRenames"):
|
|
773
|
+
with self.subTest(fixture=fixture, setting=setting):
|
|
774
|
+
self.g("reset", "--hard", head)
|
|
775
|
+
self.g("config", setting, "false")
|
|
776
|
+
with tempfile.TemporaryDirectory(prefix="native rename copy ", dir=self.root) as directory:
|
|
777
|
+
twin = Path(directory) / "repo"
|
|
778
|
+
shutil.copytree(self.repo, twin)
|
|
779
|
+
native = subprocess.run(["git", "-C", str(twin), "merge", "--no-edit", "topic"],
|
|
780
|
+
env=self.env, capture_output=True)
|
|
781
|
+
self.assertIn(native.returncode, (0, 1),
|
|
782
|
+
(native.stdout + native.stderr).decode("utf-8", errors="replace"))
|
|
783
|
+
if native.returncode == 1:
|
|
784
|
+
self.assertTrue(self.g("ls-files", "--unmerged", repo=twin))
|
|
785
|
+
self.refused(phrase=b"Merge conflicts:")
|
|
786
|
+
else:
|
|
787
|
+
self.cli()
|
|
788
|
+
self.assertEqual(self.g("rev-parse", "HEAD^{tree}"),
|
|
789
|
+
self.g("rev-parse", "HEAD^{tree}", repo=twin))
|
|
790
|
+
self.g("config", "--unset", setting)
|
|
791
|
+
|
|
792
|
+
def test_git_version_suffixes_and_early_floor(self):
|
|
793
|
+
# Prediction: vendor suffixes parse; Git 2.30 refuses before any repository work.
|
|
794
|
+
for version, expected in (("git version 2.50.1 (Apple Git-155)", (2, 50)),
|
|
795
|
+
("git version 2.47.1.windows.1", (2, 47)),
|
|
796
|
+
("git version 2.31.0", (2, 31))):
|
|
797
|
+
with self.subTest(version=version):
|
|
798
|
+
self.assertEqual(parse_git_version(version), expected)
|
|
799
|
+
check_git_version(version)
|
|
800
|
+
with self.assertRaisesRegex(Refused, r"needs Git 2.31 or newer \(found git version 2.30.9\)"):
|
|
801
|
+
check_git_version("git version 2.30.9")
|
|
802
|
+
with patch.object(Git, "text", return_value="git version 2.30.9") as text, \
|
|
803
|
+
patch("sys.stderr"):
|
|
804
|
+
self.assertEqual(bring_in(argparse.Namespace(repo=self.repo)), 1)
|
|
805
|
+
text.assert_called_once_with("--version")
|
|
806
|
+
|
|
807
|
+
|
|
808
|
+
if __name__ == "__main__":
|
|
809
|
+
runpy.run_path(str(Path(__file__).with_name("platform-support.py")))["configure_utf8"]()
|
|
810
|
+
sys.exit(main())
|