unityflow 0.3.4__py3-none-any.whl
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.
- unityflow/__init__.py +167 -0
- unityflow/asset_resolver.py +636 -0
- unityflow/asset_tracker.py +1687 -0
- unityflow/cli.py +2317 -0
- unityflow/data/__init__.py +1 -0
- unityflow/data/class_ids.json +336 -0
- unityflow/diff.py +234 -0
- unityflow/fast_parser.py +676 -0
- unityflow/formats.py +1558 -0
- unityflow/git_utils.py +307 -0
- unityflow/hierarchy.py +1672 -0
- unityflow/merge.py +226 -0
- unityflow/meta_generator.py +1291 -0
- unityflow/normalizer.py +529 -0
- unityflow/parser.py +698 -0
- unityflow/query.py +406 -0
- unityflow/script_parser.py +717 -0
- unityflow/sprite.py +378 -0
- unityflow/validator.py +783 -0
- unityflow-0.3.4.dist-info/METADATA +293 -0
- unityflow-0.3.4.dist-info/RECORD +25 -0
- unityflow-0.3.4.dist-info/WHEEL +5 -0
- unityflow-0.3.4.dist-info/entry_points.txt +2 -0
- unityflow-0.3.4.dist-info/licenses/LICENSE +21 -0
- unityflow-0.3.4.dist-info/top_level.txt +1 -0
unityflow/merge.py
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""Three-way merge for Unity YAML files.
|
|
2
|
+
|
|
3
|
+
Implements a diff3-style merge algorithm that works on normalized
|
|
4
|
+
Unity YAML content to reduce false conflicts from non-deterministic
|
|
5
|
+
serialization.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Iterator
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from difflib import SequenceMatcher
|
|
13
|
+
|
|
14
|
+
# Conflict markers
|
|
15
|
+
CONFLICT_START = "<<<<<<< ours"
|
|
16
|
+
CONFLICT_SEP = "======="
|
|
17
|
+
CONFLICT_END = ">>>>>>> theirs"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class MergeResult:
|
|
22
|
+
"""Result of a three-way merge operation."""
|
|
23
|
+
|
|
24
|
+
content: str
|
|
25
|
+
has_conflicts: bool
|
|
26
|
+
conflict_count: int = 0
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def three_way_merge(
|
|
30
|
+
base: str,
|
|
31
|
+
ours: str,
|
|
32
|
+
theirs: str,
|
|
33
|
+
) -> tuple[str, bool]:
|
|
34
|
+
"""Perform a three-way merge of text content.
|
|
35
|
+
|
|
36
|
+
This is a line-based diff3-style merge algorithm:
|
|
37
|
+
1. Find changes from base->ours and base->theirs
|
|
38
|
+
2. If changes don't overlap, merge them automatically
|
|
39
|
+
3. If changes conflict, insert conflict markers
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
base: The common ancestor content
|
|
43
|
+
ours: Our version (current branch)
|
|
44
|
+
theirs: Their version (branch being merged)
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
Tuple of (merged_content, has_conflicts)
|
|
48
|
+
"""
|
|
49
|
+
# Fast paths
|
|
50
|
+
if ours == theirs:
|
|
51
|
+
# Both made same changes (or neither changed)
|
|
52
|
+
return ours, False
|
|
53
|
+
|
|
54
|
+
if ours == base:
|
|
55
|
+
# We didn't change, take theirs
|
|
56
|
+
return theirs, False
|
|
57
|
+
|
|
58
|
+
if theirs == base:
|
|
59
|
+
# They didn't change, keep ours
|
|
60
|
+
return ours, False
|
|
61
|
+
|
|
62
|
+
# Need to do actual merge
|
|
63
|
+
base_lines = base.splitlines(keepends=True)
|
|
64
|
+
ours_lines = ours.splitlines(keepends=True)
|
|
65
|
+
theirs_lines = theirs.splitlines(keepends=True)
|
|
66
|
+
|
|
67
|
+
result = merge_lines(base_lines, ours_lines, theirs_lines)
|
|
68
|
+
|
|
69
|
+
return result.content, result.has_conflicts
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def merge_lines(
|
|
73
|
+
base: list[str],
|
|
74
|
+
ours: list[str],
|
|
75
|
+
theirs: list[str],
|
|
76
|
+
) -> MergeResult:
|
|
77
|
+
"""Merge three versions of a file at line level.
|
|
78
|
+
|
|
79
|
+
Uses a simplified diff3 algorithm:
|
|
80
|
+
1. Compute diff base->ours and base->theirs
|
|
81
|
+
2. Walk through both diffs simultaneously
|
|
82
|
+
3. Apply non-conflicting changes, mark conflicts
|
|
83
|
+
"""
|
|
84
|
+
# Get changes from base to each version
|
|
85
|
+
ours_changes = list(compute_changes(base, ours))
|
|
86
|
+
theirs_changes = list(compute_changes(base, theirs))
|
|
87
|
+
|
|
88
|
+
# Build merged result
|
|
89
|
+
result_lines: list[str] = []
|
|
90
|
+
conflict_count = 0
|
|
91
|
+
|
|
92
|
+
# Current position in base
|
|
93
|
+
base_pos = 0
|
|
94
|
+
|
|
95
|
+
# Indexes into change lists
|
|
96
|
+
ours_idx = 0
|
|
97
|
+
theirs_idx = 0
|
|
98
|
+
|
|
99
|
+
while base_pos < len(base) or ours_idx < len(ours_changes) or theirs_idx < len(theirs_changes):
|
|
100
|
+
# Get next change from each side (if any applies at current position)
|
|
101
|
+
ours_change = None
|
|
102
|
+
theirs_change = None
|
|
103
|
+
|
|
104
|
+
if ours_idx < len(ours_changes):
|
|
105
|
+
c = ours_changes[ours_idx]
|
|
106
|
+
if c.base_start <= base_pos:
|
|
107
|
+
ours_change = c
|
|
108
|
+
|
|
109
|
+
if theirs_idx < len(theirs_changes):
|
|
110
|
+
c = theirs_changes[theirs_idx]
|
|
111
|
+
if c.base_start <= base_pos:
|
|
112
|
+
theirs_change = c
|
|
113
|
+
|
|
114
|
+
# No more changes - copy rest of base
|
|
115
|
+
if ours_change is None and theirs_change is None:
|
|
116
|
+
if base_pos < len(base):
|
|
117
|
+
result_lines.append(base[base_pos])
|
|
118
|
+
base_pos += 1
|
|
119
|
+
else:
|
|
120
|
+
break
|
|
121
|
+
continue
|
|
122
|
+
|
|
123
|
+
# Only ours changed
|
|
124
|
+
if ours_change is not None and theirs_change is None:
|
|
125
|
+
result_lines.extend(ours_change.new_lines)
|
|
126
|
+
base_pos = ours_change.base_end
|
|
127
|
+
ours_idx += 1
|
|
128
|
+
continue
|
|
129
|
+
|
|
130
|
+
# Only theirs changed
|
|
131
|
+
if theirs_change is not None and ours_change is None:
|
|
132
|
+
result_lines.extend(theirs_change.new_lines)
|
|
133
|
+
base_pos = theirs_change.base_end
|
|
134
|
+
theirs_idx += 1
|
|
135
|
+
continue
|
|
136
|
+
|
|
137
|
+
# Both changed - check for conflict
|
|
138
|
+
assert ours_change is not None and theirs_change is not None
|
|
139
|
+
|
|
140
|
+
# If changes are identical, no conflict
|
|
141
|
+
if ours_change.new_lines == theirs_change.new_lines:
|
|
142
|
+
result_lines.extend(ours_change.new_lines)
|
|
143
|
+
base_pos = max(ours_change.base_end, theirs_change.base_end)
|
|
144
|
+
ours_idx += 1
|
|
145
|
+
theirs_idx += 1
|
|
146
|
+
continue
|
|
147
|
+
|
|
148
|
+
# Check if changes overlap
|
|
149
|
+
if changes_overlap(ours_change, theirs_change):
|
|
150
|
+
# Conflict!
|
|
151
|
+
conflict_count += 1
|
|
152
|
+
result_lines.append(f"{CONFLICT_START}\n")
|
|
153
|
+
result_lines.extend(ours_change.new_lines)
|
|
154
|
+
result_lines.append(f"{CONFLICT_SEP}\n")
|
|
155
|
+
result_lines.extend(theirs_change.new_lines)
|
|
156
|
+
result_lines.append(f"{CONFLICT_END}\n")
|
|
157
|
+
base_pos = max(ours_change.base_end, theirs_change.base_end)
|
|
158
|
+
ours_idx += 1
|
|
159
|
+
theirs_idx += 1
|
|
160
|
+
else:
|
|
161
|
+
# Non-overlapping changes - apply in order
|
|
162
|
+
if ours_change.base_start <= theirs_change.base_start:
|
|
163
|
+
result_lines.extend(ours_change.new_lines)
|
|
164
|
+
base_pos = ours_change.base_end
|
|
165
|
+
ours_idx += 1
|
|
166
|
+
else:
|
|
167
|
+
result_lines.extend(theirs_change.new_lines)
|
|
168
|
+
base_pos = theirs_change.base_end
|
|
169
|
+
theirs_idx += 1
|
|
170
|
+
|
|
171
|
+
content = "".join(result_lines)
|
|
172
|
+
|
|
173
|
+
# Ensure trailing newline (but not for empty content)
|
|
174
|
+
if content and not content.endswith("\n"):
|
|
175
|
+
content += "\n"
|
|
176
|
+
|
|
177
|
+
return MergeResult(
|
|
178
|
+
content=content,
|
|
179
|
+
has_conflicts=conflict_count > 0,
|
|
180
|
+
conflict_count=conflict_count,
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@dataclass
|
|
185
|
+
class Change:
|
|
186
|
+
"""Represents a change from base to a new version."""
|
|
187
|
+
|
|
188
|
+
base_start: int # Start index in base (inclusive)
|
|
189
|
+
base_end: int # End index in base (exclusive)
|
|
190
|
+
new_lines: list[str] # New lines replacing base[start:end]
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def compute_changes(base: list[str], new: list[str]) -> Iterator[Change]:
|
|
194
|
+
"""Compute the changes needed to transform base into new.
|
|
195
|
+
|
|
196
|
+
Yields Change objects representing contiguous modifications.
|
|
197
|
+
"""
|
|
198
|
+
matcher = SequenceMatcher(None, base, new, autojunk=False)
|
|
199
|
+
|
|
200
|
+
for tag, i1, i2, j1, j2 in matcher.get_opcodes():
|
|
201
|
+
if tag == "equal":
|
|
202
|
+
continue
|
|
203
|
+
elif tag == "replace":
|
|
204
|
+
yield Change(
|
|
205
|
+
base_start=i1,
|
|
206
|
+
base_end=i2,
|
|
207
|
+
new_lines=new[j1:j2],
|
|
208
|
+
)
|
|
209
|
+
elif tag == "delete":
|
|
210
|
+
yield Change(
|
|
211
|
+
base_start=i1,
|
|
212
|
+
base_end=i2,
|
|
213
|
+
new_lines=[],
|
|
214
|
+
)
|
|
215
|
+
elif tag == "insert":
|
|
216
|
+
yield Change(
|
|
217
|
+
base_start=i1,
|
|
218
|
+
base_end=i1, # Insert at position, doesn't consume base lines
|
|
219
|
+
new_lines=new[j1:j2],
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def changes_overlap(a: Change, b: Change) -> bool:
|
|
224
|
+
"""Check if two changes overlap in their base regions."""
|
|
225
|
+
# Changes overlap if their base ranges intersect
|
|
226
|
+
return not (a.base_end <= b.base_start or b.base_end <= a.base_start)
|