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/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)