jjdiff 0.1.0__tar.gz

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.
jjdiff-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,35 @@
1
+ Metadata-Version: 2.3
2
+ Name: jjdiff
3
+ Version: 0.1.0
4
+ Summary: Add your description here
5
+ Author: Daan van der Kallen
6
+ Author-email: Daan van der Kallen <mail@daanvdk.com>
7
+ Requires-Python: >=3.13
8
+ Description-Content-Type: text/markdown
9
+
10
+ # jjdiff
11
+ A TUI that can be used as a diff editor in the jujutsu vcs.
12
+
13
+ jjdiff will show all changes and allow you to navigate through them and
14
+ (partially) select them to be included.
15
+
16
+ jjdiff makes it easy to navigate the diff by having a 2 dimensional cursor that
17
+ can 'grow' and 'shrink'.
18
+
19
+ This cursor can operate on 3 levels:
20
+ - Change: select an entire change
21
+ - Hunk: select a group of edited lines in a file
22
+ - Line: select a single edited line in a file
23
+
24
+ ## Keybindings
25
+ | Command | Key | Description |
26
+ | --- | --- | --- |
27
+ | `exit` | `escape`, `ctrl+c` or `ctrl+d` | Exit the diff editor with status code 1, causing the diff to not be applied. |
28
+ | `next_cursor` | `j`, `down` or `tab` | Select the next entry. |
29
+ | `prev_cursor` | `k`, `up` or `shift+tab` | Select the previous entry. |
30
+ | `shrink_cursor` | `l` or `right` | Shrink the cursor. So go from change to hunk and from hunk to line. If the cursor is on an unopened change it will open it first. |
31
+ | `grow_cursor` | `h` or `left` | Grow the cursor. So go from line to hunk and from hunk to change. If the cursor is on an opened change it will close it. |
32
+ | `select_cursor` | `space` | Mark everything selected by the cursor to be included. If everything is already marked it will exclude it instead. This will also select the next entry. |
33
+ | `confirm` | `enter` | Confirm the selected changes. |
34
+ | `undo` | `u` | Undo the last command. Commands that only affect the UI state like changing the cursor and opening/closing changes are not included in this. |
35
+ | `redo` | `U` | Redo the last undone command. Commands that only affect the UI state like changing the cursor and opening/closing changes are not included in this. |
jjdiff-0.1.0/README.md ADDED
@@ -0,0 +1,26 @@
1
+ # jjdiff
2
+ A TUI that can be used as a diff editor in the jujutsu vcs.
3
+
4
+ jjdiff will show all changes and allow you to navigate through them and
5
+ (partially) select them to be included.
6
+
7
+ jjdiff makes it easy to navigate the diff by having a 2 dimensional cursor that
8
+ can 'grow' and 'shrink'.
9
+
10
+ This cursor can operate on 3 levels:
11
+ - Change: select an entire change
12
+ - Hunk: select a group of edited lines in a file
13
+ - Line: select a single edited line in a file
14
+
15
+ ## Keybindings
16
+ | Command | Key | Description |
17
+ | --- | --- | --- |
18
+ | `exit` | `escape`, `ctrl+c` or `ctrl+d` | Exit the diff editor with status code 1, causing the diff to not be applied. |
19
+ | `next_cursor` | `j`, `down` or `tab` | Select the next entry. |
20
+ | `prev_cursor` | `k`, `up` or `shift+tab` | Select the previous entry. |
21
+ | `shrink_cursor` | `l` or `right` | Shrink the cursor. So go from change to hunk and from hunk to line. If the cursor is on an unopened change it will open it first. |
22
+ | `grow_cursor` | `h` or `left` | Grow the cursor. So go from line to hunk and from hunk to change. If the cursor is on an opened change it will close it. |
23
+ | `select_cursor` | `space` | Mark everything selected by the cursor to be included. If everything is already marked it will exclude it instead. This will also select the next entry. |
24
+ | `confirm` | `enter` | Confirm the selected changes. |
25
+ | `undo` | `u` | Undo the last command. Commands that only affect the UI state like changing the cursor and opening/closing changes are not included in this. |
26
+ | `redo` | `U` | Redo the last undone command. Commands that only affect the UI state like changing the cursor and opening/closing changes are not included in this. |
@@ -0,0 +1,29 @@
1
+ [project]
2
+ name = "jjdiff"
3
+ version = "0.1.0"
4
+ description = "Add your description here"
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "Daan van der Kallen", email = "mail@daanvdk.com" }
8
+ ]
9
+ requires-python = ">=3.13"
10
+ dependencies = []
11
+
12
+ [project.scripts]
13
+ jjdiff = "jjdiff:main"
14
+
15
+ [tool.pyright]
16
+ venvPath = "."
17
+ venv = ".venv"
18
+ reportUnusedCallResult = false
19
+
20
+ [build-system]
21
+ requires = ["uv_build>=0.8.0,<0.9"]
22
+ build-backend = "uv_build"
23
+
24
+ [dependency-groups]
25
+ dev = [
26
+ "mypy>=1.17.0",
27
+ "pytest>=8.4.1",
28
+ "ruff>=0.12.5",
29
+ ]
@@ -0,0 +1,24 @@
1
+ import argparse
2
+ from pathlib import Path
3
+ from typing import cast
4
+
5
+ from .change import apply_changes, reverse_changes
6
+ from .diff import diff
7
+ from .edit import edit_changes
8
+
9
+
10
+ parser = argparse.ArgumentParser()
11
+ parser.add_argument("old", type=Path)
12
+ parser.add_argument("new", type=Path)
13
+
14
+
15
+ def main() -> None:
16
+ args = parser.parse_args()
17
+ old = cast(Path, args.old)
18
+ new = cast(Path, args.new)
19
+
20
+ new.joinpath("JJ-INSTRUCTIONS").unlink()
21
+
22
+ changes = tuple(diff(old, new))
23
+ apply_changes(new, reverse_changes(changes))
24
+ apply_changes(new, edit_changes(changes))
@@ -0,0 +1,287 @@
1
+ from collections.abc import Iterable, Iterator, Sequence
2
+ from dataclasses import dataclass
3
+ from pathlib import Path
4
+ import stat
5
+ from typing import Literal
6
+
7
+
8
+ type LineStatus = Literal["added", "deleted", "changed", "unchanged"]
9
+
10
+
11
+ @dataclass
12
+ class Line:
13
+ old: str | None
14
+ new: str | None
15
+
16
+ @property
17
+ def status(self) -> LineStatus:
18
+ if self.old is None:
19
+ return "added"
20
+ elif self.new is None:
21
+ return "deleted"
22
+ elif self.old != self.new:
23
+ return "changed"
24
+ else:
25
+ return "unchanged"
26
+
27
+
28
+ @dataclass
29
+ class Rename:
30
+ old_path: Path
31
+ new_path: Path
32
+
33
+
34
+ @dataclass
35
+ class ChangeMode:
36
+ path: Path
37
+ old_is_exec: bool
38
+ new_is_exec: bool
39
+
40
+
41
+ @dataclass
42
+ class AddFile:
43
+ path: Path
44
+ lines: list[Line]
45
+ is_exec: bool
46
+
47
+
48
+ @dataclass
49
+ class ModifyFile:
50
+ path: Path
51
+ lines: list[Line]
52
+
53
+
54
+ @dataclass
55
+ class DeleteFile:
56
+ path: Path
57
+ lines: list[Line]
58
+ is_exec: bool
59
+
60
+
61
+ @dataclass
62
+ class AddBinary:
63
+ path: Path
64
+ data: bytes
65
+ is_exec: bool
66
+
67
+
68
+ @dataclass
69
+ class ModifyBinary:
70
+ path: Path
71
+ old_data: bytes
72
+ new_data: bytes
73
+
74
+
75
+ @dataclass
76
+ class DeleteBinary:
77
+ path: Path
78
+ data: bytes
79
+ is_exec: bool
80
+
81
+
82
+ @dataclass
83
+ class AddSymlink:
84
+ path: Path
85
+ to: Path
86
+
87
+
88
+ @dataclass
89
+ class ModifySymlink:
90
+ path: Path
91
+ old_to: Path
92
+ new_to: Path
93
+
94
+
95
+ @dataclass
96
+ class DeleteSymlink:
97
+ path: Path
98
+ to: Path
99
+
100
+
101
+ type FileChange = AddFile | ModifyFile | DeleteFile
102
+ type BinaryChange = AddBinary | ModifyBinary | DeleteBinary
103
+ type SymlinkChange = AddSymlink | ModifySymlink | DeleteSymlink
104
+ type Change = Rename | ChangeMode | FileChange | BinaryChange | SymlinkChange
105
+
106
+
107
+ FILE_CHANGE_TYPES = (AddFile, ModifyFile, DeleteFile)
108
+
109
+
110
+ def reverse_changes(changes: Sequence[Change]) -> Iterator[Change]:
111
+ renames: dict[Path, Path] = {}
112
+ for change in changes:
113
+ if isinstance(change, Rename):
114
+ renames[change.old_path] = change.new_path
115
+
116
+ for change in reversed(changes):
117
+ match change:
118
+ case Rename(old_path, new_path):
119
+ yield Rename(new_path, old_path)
120
+
121
+ case ChangeMode(path, old_is_exec, new_is_exec):
122
+ path = renames.get(path, path)
123
+ yield ChangeMode(path, new_is_exec, old_is_exec)
124
+
125
+ case AddFile(path, lines, is_exec):
126
+ path = renames.get(path, path)
127
+ yield DeleteFile(path, reverse_lines(lines), is_exec)
128
+
129
+ case ModifyFile(path, lines):
130
+ path = renames.get(path, path)
131
+ yield ModifyFile(path, reverse_lines(lines))
132
+
133
+ case DeleteFile(path, lines, is_exec):
134
+ path = renames.get(path, path)
135
+ yield AddFile(path, reverse_lines(lines), is_exec)
136
+
137
+ case AddBinary(path, data, is_exec):
138
+ path = renames.get(path, path)
139
+ yield DeleteBinary(path, data, is_exec)
140
+
141
+ case ModifyBinary(path, old_data, new_data):
142
+ path = renames.get(path, path)
143
+ yield ModifyBinary(path, new_data, old_data)
144
+
145
+ case DeleteBinary(path, data, is_exec):
146
+ path = renames.get(path, path)
147
+ yield AddBinary(path, data, is_exec)
148
+
149
+ case AddSymlink(path, to):
150
+ path = renames.get(path, path)
151
+ yield DeleteSymlink(path, to)
152
+
153
+ case ModifySymlink(path, old_to, new_to):
154
+ path = renames.get(path, path)
155
+ yield ModifySymlink(path, new_to, old_to)
156
+
157
+ case DeleteSymlink(path, to):
158
+ path = renames.get(path, path)
159
+ yield AddSymlink(path, to)
160
+
161
+
162
+ def reverse_lines(lines: list[Line]) -> list[Line]:
163
+ return [Line(line.new, line.old) for line in lines]
164
+
165
+
166
+ @dataclass(frozen=True)
167
+ class ChangeRef:
168
+ change: int
169
+
170
+
171
+ @dataclass(frozen=True)
172
+ class LineRef:
173
+ change: int
174
+ line: int
175
+
176
+
177
+ type Ref = ChangeRef | LineRef
178
+
179
+
180
+ def filter_changes(
181
+ refs: set[Ref],
182
+ changes: Iterable[Change],
183
+ ) -> Iterator[Change]:
184
+ for change_index, change in enumerate(changes):
185
+ change_ref = ChangeRef(change_index)
186
+
187
+ # For non file changes we just include the whole change or not
188
+ if not isinstance(change, FILE_CHANGE_TYPES):
189
+ if change_ref in refs:
190
+ yield change
191
+ continue
192
+
193
+ # Now that we know we have a file change, we first filter the lines
194
+ lines: list[Line] = []
195
+ line_changes = False
196
+
197
+ for line_index, line in enumerate(change.lines):
198
+ line_ref = LineRef(change_index, line_index)
199
+ if line_ref in refs:
200
+ lines.append(line)
201
+ line_changes = True
202
+ elif line.old is not None:
203
+ lines.append(Line(line.old, line.old))
204
+
205
+ # Now we can check what the filtered change looks like
206
+ match change:
207
+ case AddFile(path, _, is_exec):
208
+ if change_ref in refs:
209
+ yield AddFile(path, lines, is_exec)
210
+
211
+ case ModifyFile(path):
212
+ if line_changes:
213
+ yield ModifyFile(path, lines)
214
+
215
+ case DeleteFile(path, _, is_exec):
216
+ if change_ref in refs:
217
+ yield DeleteFile(path, lines, is_exec)
218
+ elif line_changes:
219
+ yield ModifyFile(path, lines)
220
+
221
+
222
+ def apply_changes(root: Path, changes: Iterable[Change]) -> None:
223
+ for change in changes:
224
+ apply_change(root, change)
225
+
226
+
227
+ def apply_change(root: Path, change: Change) -> None:
228
+ renames: dict[Path, Path] = {}
229
+
230
+ match change:
231
+ case Rename(old_path, new_path):
232
+ full_old_path = root / old_path
233
+ full_new_path = root / new_path
234
+ full_old_path.rename(full_new_path)
235
+
236
+ case ChangeMode(path, _, is_exec):
237
+ path = renames.get(path, path)
238
+ full_path = root / path
239
+
240
+ mode = full_path.stat().st_mode
241
+ if is_exec:
242
+ mode |= stat.S_IXUSR
243
+ else:
244
+ mode &= ~stat.S_IXUSR
245
+ full_path.chmod(mode)
246
+
247
+ case (
248
+ AddFile(path)
249
+ | ModifyFile(path)
250
+ | AddBinary(path)
251
+ | ModifyBinary(path)
252
+ | AddSymlink(path)
253
+ | ModifySymlink(path)
254
+ ):
255
+ path = renames.get(path, path)
256
+ full_path = root / path
257
+
258
+ full_path.parent.mkdir(parents=True, exist_ok=True)
259
+
260
+ match change:
261
+ case AddFile(_, lines) | ModifyFile(_, lines):
262
+ full_path.write_text(lines_to_text(lines))
263
+ case AddBinary(_, data) | ModifyBinary(_, _, data):
264
+ full_path.write_bytes(data)
265
+ case AddSymlink(_, to) | ModifySymlink(_, _, to):
266
+ full_path.symlink_to(to)
267
+
268
+ if isinstance(change, (AddFile, AddBinary)) and change.is_exec:
269
+ mode = full_path.stat().st_mode
270
+ mode |= stat.S_IXUSR
271
+ full_path.chmod(mode)
272
+
273
+ case DeleteFile(path) | DeleteBinary(path) | DeleteSymlink(path):
274
+ path = renames.get(path, path)
275
+ full_path = root / path
276
+
277
+ full_path.unlink()
278
+
279
+ full_path = full_path.parent
280
+ while full_path != root and not any(full_path.iterdir()):
281
+ full_path.relative_to(root)
282
+ full_path.rmdir()
283
+ full_path = full_path.parent
284
+
285
+
286
+ def lines_to_text(lines: list[Line]) -> str:
287
+ return "\n".join(line.new for line in lines if line.new is not None)
@@ -0,0 +1,13 @@
1
+ from collections.abc import Iterable
2
+
3
+ from .drawable import Drawable
4
+ from .grid import Grid
5
+
6
+
7
+ class Cols(Grid):
8
+ def __init__(self, drawables: Iterable[Drawable]):
9
+ drawables = tuple(drawables)
10
+ super().__init__(
11
+ tuple(1 for _ in drawables),
12
+ [drawables],
13
+ )