cherry-files-toolkit 0.1.0__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.
- cherry_files_toolkit/__init__.py +0 -0
- cherry_files_toolkit/main.py +39 -0
- cherry_files_toolkit/modules/__init__.py +2 -0
- cherry_files_toolkit/modules/cherry_files_diff.py +244 -0
- cherry_files_toolkit/modules/cherry_files_picker.py +227 -0
- cherry_files_toolkit/utilities/__init__.py +2 -0
- cherry_files_toolkit/utilities/git_functions.py +67 -0
- cherry_files_toolkit/widgets/__init__.py +2 -0
- cherry_files_toolkit/widgets/filterable_option_picker.py +74 -0
- cherry_files_toolkit-0.1.0.dist-info/METADATA +103 -0
- cherry_files_toolkit-0.1.0.dist-info/RECORD +14 -0
- cherry_files_toolkit-0.1.0.dist-info/WHEEL +4 -0
- cherry_files_toolkit-0.1.0.dist-info/entry_points.txt +2 -0
- cherry_files_toolkit-0.1.0.dist-info/licenses/LICENSE +21 -0
|
File without changes
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from textual.app import App, ComposeResult
|
|
2
|
+
from textual.binding import Binding
|
|
3
|
+
from textual.widgets import Header, Footer
|
|
4
|
+
from textual.containers import Horizontal
|
|
5
|
+
|
|
6
|
+
from cherry_files_toolkit.modules.cherry_files_diff import CherryFilesDiff
|
|
7
|
+
from cherry_files_toolkit.modules.cherry_files_picker import CherryFilesPicker
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class MyApp(App):
|
|
11
|
+
CSS = """
|
|
12
|
+
CherryFilesDiff {
|
|
13
|
+
width: 40%;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
CherryFilesPicker {
|
|
17
|
+
width: 60%;
|
|
18
|
+
}
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
BINDINGS = [
|
|
22
|
+
Binding(key="ctrl+q", action="quit", description="[Ctrl+q]Quit the app")
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
def compose(self) -> ComposeResult:
|
|
26
|
+
with Horizontal():
|
|
27
|
+
yield Header(icon="🍒")
|
|
28
|
+
yield CherryFilesDiff()
|
|
29
|
+
yield CherryFilesPicker()
|
|
30
|
+
yield Footer(show_command_palette=True)
|
|
31
|
+
|
|
32
|
+
def on_mount(self) -> None:
|
|
33
|
+
self.title = "Cherry Files Toolkit"
|
|
34
|
+
|
|
35
|
+
def run() -> None:
|
|
36
|
+
MyApp().run()
|
|
37
|
+
|
|
38
|
+
if __name__ == "__main__":
|
|
39
|
+
run()
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
from textual.app import ComposeResult
|
|
2
|
+
from textual import on
|
|
3
|
+
from textual.containers import Horizontal, Vertical, VerticalScroll
|
|
4
|
+
from textual.widgets import Button, Label
|
|
5
|
+
|
|
6
|
+
from cherry_files_toolkit.utilities.git_functions import (
|
|
7
|
+
get_branches,
|
|
8
|
+
get_divergence_point,
|
|
9
|
+
get_all_changed_files,
|
|
10
|
+
)
|
|
11
|
+
from cherry_files_toolkit.widgets.filterable_option_picker import FilterableOptionPicker
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class CherryFilesDiff(Vertical):
|
|
15
|
+
DEFAULT_CSS = """
|
|
16
|
+
#cherry_files_diff_summary_label,
|
|
17
|
+
#divergent_branch Label,
|
|
18
|
+
#base_branch Label {
|
|
19
|
+
text-style: bold;
|
|
20
|
+
padding-bottom: 1;
|
|
21
|
+
padding-top: 1;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
#cherry_files_diff_summary_header {
|
|
25
|
+
height: 1;
|
|
26
|
+
margin-bottom: 0;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
#files_scroll_container {
|
|
30
|
+
margin-top: 1;
|
|
31
|
+
padding-bottom: 1;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
#files_scroll_container .cherry_files_diff_added_files,
|
|
35
|
+
#files_scroll_container .cherry_files_diff_modified_files,
|
|
36
|
+
#files_scroll_container .cherry_files_diff_deleted_files {
|
|
37
|
+
text-style: bold;
|
|
38
|
+
padding-bottom: 1;
|
|
39
|
+
padding-top: 1;
|
|
40
|
+
}
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def compose(self) -> ComposeResult:
|
|
44
|
+
with Vertical(id="cherry_files_diff"):
|
|
45
|
+
yield Label(
|
|
46
|
+
"Cherry Files Diff", classes="header", variant="primary", expand=True
|
|
47
|
+
)
|
|
48
|
+
with Vertical(classes="branches_selector"):
|
|
49
|
+
with Horizontal():
|
|
50
|
+
yield FilterableOptionPicker(
|
|
51
|
+
label="[1] Divergent Branch",
|
|
52
|
+
id="divergent_branch",
|
|
53
|
+
classes="container",
|
|
54
|
+
)
|
|
55
|
+
yield FilterableOptionPicker(
|
|
56
|
+
label="[2] Base Branch", id="base_branch", classes="container"
|
|
57
|
+
)
|
|
58
|
+
yield Button(
|
|
59
|
+
"Start Diff Checker",
|
|
60
|
+
id="start_diff_checker",
|
|
61
|
+
variant="primary",
|
|
62
|
+
flat=True,
|
|
63
|
+
)
|
|
64
|
+
with Vertical(id="cherry_files_diff_summary"):
|
|
65
|
+
yield Label("[3] Summary", id="cherry_files_diff_summary_label")
|
|
66
|
+
with Horizontal(id="cherry_files_diff_summary_header"):
|
|
67
|
+
yield Label(
|
|
68
|
+
id="cherry_files_diff_summary_added",
|
|
69
|
+
classes="cherry_files_diff_added_files",
|
|
70
|
+
)
|
|
71
|
+
yield Label(
|
|
72
|
+
id="cherry_files_diff_summary_modified",
|
|
73
|
+
classes="cherry_files_diff_modified_files",
|
|
74
|
+
)
|
|
75
|
+
yield Label(
|
|
76
|
+
id="cherry_files_diff_summary_deleted",
|
|
77
|
+
classes="cherry_files_diff_deleted_files",
|
|
78
|
+
)
|
|
79
|
+
yield Label(id="cherry_files_diff_summary_counter")
|
|
80
|
+
yield VerticalScroll(id="files_scroll_container")
|
|
81
|
+
|
|
82
|
+
def on_mount(self) -> None:
|
|
83
|
+
branches = get_branches()
|
|
84
|
+
if len(branches) == 0:
|
|
85
|
+
self.notify(
|
|
86
|
+
"No branches found. Please make sure to be in a git repo",
|
|
87
|
+
severity="error",
|
|
88
|
+
)
|
|
89
|
+
return
|
|
90
|
+
self.query_one("#base_branch", FilterableOptionPicker).set_items(branches)
|
|
91
|
+
self.query_one("#divergent_branch", FilterableOptionPicker).set_items(branches)
|
|
92
|
+
|
|
93
|
+
@on(Button.Pressed, "#start_diff_checker")
|
|
94
|
+
async def start_diff_checker_handler(self) -> None:
|
|
95
|
+
base_branch = self.query_one(
|
|
96
|
+
"#base_branch", FilterableOptionPicker
|
|
97
|
+
).get_search_value()
|
|
98
|
+
divergent_branch = self.query_one(
|
|
99
|
+
"#divergent_branch", FilterableOptionPicker
|
|
100
|
+
).get_search_value()
|
|
101
|
+
|
|
102
|
+
if base_branch.strip() == "":
|
|
103
|
+
self.notify(
|
|
104
|
+
"Please select a Base Branch",
|
|
105
|
+
title="Cherry Diff Files",
|
|
106
|
+
severity="warning",
|
|
107
|
+
)
|
|
108
|
+
return
|
|
109
|
+
|
|
110
|
+
if divergent_branch.strip() == "":
|
|
111
|
+
self.notify(
|
|
112
|
+
"Please select a Divergent Branch",
|
|
113
|
+
title="Cherry Diff Files",
|
|
114
|
+
severity="warning",
|
|
115
|
+
)
|
|
116
|
+
return
|
|
117
|
+
|
|
118
|
+
if base_branch.strip() == divergent_branch.strip():
|
|
119
|
+
self.notify(
|
|
120
|
+
"Base and Divergent branches must be different",
|
|
121
|
+
severity="error",
|
|
122
|
+
title="Cherry Diff Files",
|
|
123
|
+
)
|
|
124
|
+
return
|
|
125
|
+
|
|
126
|
+
await self.files_diff(base_branch, divergent_branch)
|
|
127
|
+
|
|
128
|
+
async def files_diff(self, base: str, divergent: str) -> None:
|
|
129
|
+
commit_id = get_divergence_point(base=base, branch=divergent)
|
|
130
|
+
changed_files = get_all_changed_files(divergent=divergent, commit_id=commit_id)
|
|
131
|
+
added_files, modified_files, deleted_files = self.parsed_files(changed_files)
|
|
132
|
+
await self.render_files(added_files, modified_files, deleted_files)
|
|
133
|
+
|
|
134
|
+
async def render_files(self, added_files, modified_files, deleted_files) -> None:
|
|
135
|
+
files_container = self.query_one("#files_scroll_container", VerticalScroll)
|
|
136
|
+
cherry_files_diff_summary_added = self.query_one(
|
|
137
|
+
"#cherry_files_diff_summary_added", Label
|
|
138
|
+
)
|
|
139
|
+
cherry_files_diff_summary_modified = self.query_one(
|
|
140
|
+
"#cherry_files_diff_summary_modified", Label
|
|
141
|
+
)
|
|
142
|
+
cherry_files_diff_summary_deleted = self.query_one(
|
|
143
|
+
"#cherry_files_diff_summary_deleted", Label
|
|
144
|
+
)
|
|
145
|
+
cherry_files_diff_summary_counter = self.query_one(
|
|
146
|
+
"#cherry_files_diff_summary_counter", Label
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
added_files_counter = len(added_files)
|
|
150
|
+
modified_files_counter = len(modified_files)
|
|
151
|
+
deleted_files_counter = len(deleted_files)
|
|
152
|
+
|
|
153
|
+
# SUMMARY
|
|
154
|
+
cherry_files_diff_summary_added.update(f"+{added_files_counter} created || ")
|
|
155
|
+
cherry_files_diff_summary_modified.update(
|
|
156
|
+
f"~{modified_files_counter} modified || "
|
|
157
|
+
)
|
|
158
|
+
cherry_files_diff_summary_deleted.update(
|
|
159
|
+
f"-{deleted_files_counter} deleted || "
|
|
160
|
+
)
|
|
161
|
+
cherry_files_diff_summary_counter.update(
|
|
162
|
+
f"Total: {added_files_counter + modified_files_counter + deleted_files_counter} files touched"
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
# Render trees
|
|
166
|
+
await files_container.remove_children()
|
|
167
|
+
await self.render_tree_files(
|
|
168
|
+
"CREATED FILES", "cherry_files_diff_added_files", added_files
|
|
169
|
+
)
|
|
170
|
+
await self.render_tree_files(
|
|
171
|
+
"MODIFIED FILES", "cherry_files_diff_modified_files", modified_files
|
|
172
|
+
)
|
|
173
|
+
await self.render_tree_files(
|
|
174
|
+
"DELETED FILES", "cherry_files_diff_deleted_files", deleted_files
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
async def render_tree_files(
|
|
178
|
+
self, label: str, label_class: str, files: list[str]
|
|
179
|
+
) -> None:
|
|
180
|
+
files_container = self.query_one("#files_scroll_container", VerticalScroll)
|
|
181
|
+
await files_container.mount(Label(label, classes=label_class))
|
|
182
|
+
if len(files) == 0:
|
|
183
|
+
return
|
|
184
|
+
await files_container.mount(Label("📂 root", classes="parent_directory"))
|
|
185
|
+
tree = self.build_tree(files)
|
|
186
|
+
await self.render_tree(tree)
|
|
187
|
+
|
|
188
|
+
async def render_tree(self, tree, prefix=""):
|
|
189
|
+
files_container = self.query_one("#files_scroll_container", VerticalScroll)
|
|
190
|
+
entries = list(tree.items())
|
|
191
|
+
for i, (name, subtree) in enumerate(entries):
|
|
192
|
+
is_last = i == len(entries) - 1
|
|
193
|
+
connector = ""
|
|
194
|
+
if is_last and not subtree:
|
|
195
|
+
connector = "└── "
|
|
196
|
+
elif subtree:
|
|
197
|
+
connector = "├── 📂 "
|
|
198
|
+
else:
|
|
199
|
+
connector = "├── "
|
|
200
|
+
# connector = if is_last and not subtree elif subtree "-" else "├── "
|
|
201
|
+
classes = "parent_directory" if subtree else ""
|
|
202
|
+
await files_container.mount(
|
|
203
|
+
Label(prefix + connector + name, classes=classes)
|
|
204
|
+
)
|
|
205
|
+
self.log(prefix + connector + name)
|
|
206
|
+
if subtree:
|
|
207
|
+
extension = " " if is_last else "│ "
|
|
208
|
+
await self.render_tree(subtree, prefix + extension)
|
|
209
|
+
|
|
210
|
+
def parsed_files(self, changed_files):
|
|
211
|
+
added_files = []
|
|
212
|
+
modified_files = []
|
|
213
|
+
deleted_files = []
|
|
214
|
+
|
|
215
|
+
for line in changed_files:
|
|
216
|
+
parts = line.split("\t")
|
|
217
|
+
|
|
218
|
+
if len(parts) < 2:
|
|
219
|
+
continue
|
|
220
|
+
|
|
221
|
+
status = parts[0]
|
|
222
|
+
path = parts[-1]
|
|
223
|
+
|
|
224
|
+
if status == "A":
|
|
225
|
+
added_files.append(path)
|
|
226
|
+
if status == "M":
|
|
227
|
+
modified_files.append(path)
|
|
228
|
+
if status == "D":
|
|
229
|
+
deleted_files.append(path)
|
|
230
|
+
if status.startswith(("R", "C")):
|
|
231
|
+
modified_files.append(path)
|
|
232
|
+
|
|
233
|
+
return added_files, modified_files, deleted_files
|
|
234
|
+
|
|
235
|
+
def build_tree(self, files: list[str]):
|
|
236
|
+
tree = {}
|
|
237
|
+
for path in files:
|
|
238
|
+
node = tree
|
|
239
|
+
parts = path.split("/")
|
|
240
|
+
for part in parts:
|
|
241
|
+
if part not in node:
|
|
242
|
+
node[part] = {}
|
|
243
|
+
node = node[part]
|
|
244
|
+
return tree
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from textual import on
|
|
3
|
+
from textual.app import ComposeResult
|
|
4
|
+
from textual.containers import Vertical, Horizontal, VerticalScroll
|
|
5
|
+
from textual.widgets import Button, Input, Label, SelectionList
|
|
6
|
+
|
|
7
|
+
from cherry_files_toolkit.utilities.git_functions import get_branches, get_current_branch, cherry_pick_files
|
|
8
|
+
from cherry_files_toolkit.widgets.filterable_option_picker import FilterableOptionPicker
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class CherryFilesPicker(Vertical):
|
|
12
|
+
DEFAULT_CSS = """
|
|
13
|
+
/* Fills the screen and never scrolls itself: one child (#body)
|
|
14
|
+
is 1fr and absorbs all the leftover height. */
|
|
15
|
+
#cherry_files_picker {
|
|
16
|
+
height: 1fr;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/* Top chrome: as tall as its content. The row and its columns must
|
|
20
|
+
be auto, otherwise the containers default to height: 1fr and eat
|
|
21
|
+
the whole panel, pushing #body off-screen. */
|
|
22
|
+
.branches_selector {
|
|
23
|
+
height: auto;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
#branch_row {
|
|
27
|
+
height: auto;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
#source_branch_col {
|
|
31
|
+
height: auto;
|
|
32
|
+
width: 1fr;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
#target_branch {
|
|
36
|
+
width: 1fr;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/* Numbered section headings are bold. */
|
|
40
|
+
.section_label,
|
|
41
|
+
#target_branch Label {
|
|
42
|
+
text-style: bold;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/* The flexible body: two columns sharing all remaining height. */
|
|
46
|
+
#body {
|
|
47
|
+
height: 1fr;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/* Left column flexes to fill width; its list absorbs the column
|
|
51
|
+
height and scrolls internally instead of growing to every row. */
|
|
52
|
+
#files_panel {
|
|
53
|
+
width: 1fr;
|
|
54
|
+
height: 1fr;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
#files_scroll_container {
|
|
58
|
+
height: 1fr;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/* Right column: fixed-width commit form. */
|
|
62
|
+
#commit_container {
|
|
63
|
+
width: 45;
|
|
64
|
+
height: 1fr;
|
|
65
|
+
margin-left: 1;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/* Form fields size to content; the selected-files list soaks up
|
|
69
|
+
whatever height is left in the column and scrolls internally. */
|
|
70
|
+
.commit_section_item {
|
|
71
|
+
height: auto;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
#selected_files_item {
|
|
75
|
+
height: 1fr;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
#selected_files_container {
|
|
79
|
+
height: 1fr;
|
|
80
|
+
border: solid white;
|
|
81
|
+
}
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
def compose(self) -> ComposeResult:
|
|
85
|
+
with Vertical(id="cherry_files_picker"):
|
|
86
|
+
yield Label(
|
|
87
|
+
"Cherry Files Picker", classes="header", variant="success", expand=True
|
|
88
|
+
)
|
|
89
|
+
with Vertical(classes="branches_selector"):
|
|
90
|
+
with Horizontal(id="branch_row"):
|
|
91
|
+
with Vertical(id="source_branch_col"):
|
|
92
|
+
yield Label("[1] Source Branch", classes="section_label")
|
|
93
|
+
yield Input(id="source_branch", disabled=True)
|
|
94
|
+
yield FilterableOptionPicker(
|
|
95
|
+
label="[2] Target Branch",
|
|
96
|
+
id="target_branch",
|
|
97
|
+
classes="container",
|
|
98
|
+
)
|
|
99
|
+
with Horizontal(id="body"):
|
|
100
|
+
with Vertical(id="files_panel", classes="container"):
|
|
101
|
+
yield Label("[3] Files", classes="section_label")
|
|
102
|
+
yield Input(
|
|
103
|
+
placeholder="Type to search files", id="search_files_input"
|
|
104
|
+
)
|
|
105
|
+
yield SelectionList(id="files_scroll_container")
|
|
106
|
+
with Vertical(id="commit_container", classes="container"):
|
|
107
|
+
yield Label("[4] Commit", classes="section_label")
|
|
108
|
+
with Vertical(classes="commit_section_item"):
|
|
109
|
+
yield Label("Commit title")
|
|
110
|
+
yield Input(id="commit_title")
|
|
111
|
+
with Vertical(classes="commit_section_item"):
|
|
112
|
+
yield Label("Commit description")
|
|
113
|
+
yield Input(id="commit_description")
|
|
114
|
+
with Vertical(
|
|
115
|
+
id="selected_files_item", classes="commit_section_item"
|
|
116
|
+
):
|
|
117
|
+
yield Label("Selected files")
|
|
118
|
+
yield VerticalScroll(id="selected_files_container")
|
|
119
|
+
yield Button(
|
|
120
|
+
label="Cherry Pick Files",
|
|
121
|
+
id="cta_cherry_pick_files",
|
|
122
|
+
variant="success",
|
|
123
|
+
compact=True,
|
|
124
|
+
flat=True,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
def on_mount(self) -> None:
|
|
128
|
+
branches = get_branches()
|
|
129
|
+
if len(branches) == 0:
|
|
130
|
+
self.notify(
|
|
131
|
+
"No branches found. Please make sure to be in a git repo",
|
|
132
|
+
severity="error",
|
|
133
|
+
)
|
|
134
|
+
return
|
|
135
|
+
self.query_one("#target_branch", FilterableOptionPicker).set_items(branches)
|
|
136
|
+
current_branch = get_current_branch()
|
|
137
|
+
if current_branch:
|
|
138
|
+
self.query_one("#source_branch", Input).value = current_branch
|
|
139
|
+
self.selected_files = []
|
|
140
|
+
self.all_files = self.get_all_project_files()
|
|
141
|
+
self.render_file_options(self.all_files)
|
|
142
|
+
|
|
143
|
+
@on(Input.Changed, "#search_files_input")
|
|
144
|
+
def filter(self, event: Input.Changed):
|
|
145
|
+
text = event.value.strip().lower()
|
|
146
|
+
if len(text) < 4:
|
|
147
|
+
self.render_file_options(self.all_files)
|
|
148
|
+
return
|
|
149
|
+
|
|
150
|
+
filtered_files = [x for x in self.all_files if text in x.lower()]
|
|
151
|
+
self.render_file_options(filtered_files)
|
|
152
|
+
|
|
153
|
+
@on(SelectionList.SelectedChanged, "#files_scroll_container")
|
|
154
|
+
async def files_selected_changed(
|
|
155
|
+
self, event: SelectionList.SelectedChanged[str]
|
|
156
|
+
) -> None:
|
|
157
|
+
currently_selected = set(event.selection_list.selected)
|
|
158
|
+
visible = getattr(self, "_visible_files", set())
|
|
159
|
+
updated = [f for f in self.selected_files if f not in visible]
|
|
160
|
+
updated.extend(currently_selected)
|
|
161
|
+
self.selected_files = updated
|
|
162
|
+
await self.render_selected_files_list()
|
|
163
|
+
|
|
164
|
+
@on(Button.Pressed, "#cta_cherry_pick_files")
|
|
165
|
+
def cta_cherry_pick_files_handler(self) -> None:
|
|
166
|
+
target_branch = self.query_one(
|
|
167
|
+
"#target_branch", FilterableOptionPicker
|
|
168
|
+
).get_search_value()
|
|
169
|
+
source_branch = self.query_one("#source_branch", Input).value
|
|
170
|
+
selected_files = self.selected_files
|
|
171
|
+
commit_title = self.query_one("#commit_title", Input).value
|
|
172
|
+
commit_description = self.query_one("#commit_description", Input).value
|
|
173
|
+
|
|
174
|
+
if target_branch.strip() == "":
|
|
175
|
+
self.notify("Please select a Target Branch", severity="warning")
|
|
176
|
+
return
|
|
177
|
+
if len(selected_files) == 0:
|
|
178
|
+
self.notify("Please select at least one file", severity="warning")
|
|
179
|
+
return
|
|
180
|
+
|
|
181
|
+
success, message = cherry_pick_files(
|
|
182
|
+
source_branch,
|
|
183
|
+
target_branch,
|
|
184
|
+
selected_files,
|
|
185
|
+
commit_title,
|
|
186
|
+
commit_description,
|
|
187
|
+
)
|
|
188
|
+
if success:
|
|
189
|
+
self.notify(message, severity="information")
|
|
190
|
+
self.reset_form()
|
|
191
|
+
else:
|
|
192
|
+
self.notify(message, severity="error")
|
|
193
|
+
|
|
194
|
+
def reset_form(self):
|
|
195
|
+
self.query_one("#target_branch").query_one("#search", Input).value = ""
|
|
196
|
+
self.query_one("#search_files_input", Input).value = ""
|
|
197
|
+
self.query_one("#commit_title", Input).value = ""
|
|
198
|
+
self.query_one("#commit_description", Input).value = ""
|
|
199
|
+
self.selected_files = []
|
|
200
|
+
self.render_file_options(self.all_files)
|
|
201
|
+
self.query_one("#selected_files_container", VerticalScroll).remove_children()
|
|
202
|
+
|
|
203
|
+
async def render_selected_files_list(self):
|
|
204
|
+
list_container = self.query_one("#selected_files_container", VerticalScroll)
|
|
205
|
+
await list_container.remove_children()
|
|
206
|
+
for item in self.selected_files:
|
|
207
|
+
await list_container.mount(Label(item))
|
|
208
|
+
|
|
209
|
+
def render_file_options(self, filtered_files):
|
|
210
|
+
selection_list = self.query_one("#files_scroll_container", SelectionList)
|
|
211
|
+
self._visible_files = set(filtered_files)
|
|
212
|
+
selection_list.clear_options()
|
|
213
|
+
selection_list.add_options(
|
|
214
|
+
(file_path, file_path, file_path in self.selected_files)
|
|
215
|
+
for file_path in filtered_files
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
def get_all_project_files(self) -> list[str]:
|
|
219
|
+
root_path = Path(".")
|
|
220
|
+
files: list[str] = []
|
|
221
|
+
skipped_directories = {".git", ".venv", "node_modules", "__pycache__"}
|
|
222
|
+
for path in root_path.rglob("*"):
|
|
223
|
+
if any(part in skipped_directories for part in path.parts):
|
|
224
|
+
continue
|
|
225
|
+
if path.is_file():
|
|
226
|
+
files.append(path.relative_to(root_path).as_posix())
|
|
227
|
+
return files
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
|
|
3
|
+
def is_git_repo() -> bool:
|
|
4
|
+
result = subprocess.run(["git","rev-parse", "--is-inside-work-tree"], capture_output=True, text=True)
|
|
5
|
+
if result.stdout.strip() == "true":
|
|
6
|
+
return True
|
|
7
|
+
else:
|
|
8
|
+
return False
|
|
9
|
+
|
|
10
|
+
def get_current_branch() -> str:
|
|
11
|
+
result = subprocess.run(["git", "rev-parse", "--abbrev-ref", "HEAD"], capture_output=True, text=True)
|
|
12
|
+
return result.stdout.strip()
|
|
13
|
+
|
|
14
|
+
def get_branches() -> list[str]:
|
|
15
|
+
"Returns an array with the repository branches"
|
|
16
|
+
if not is_git_repo():
|
|
17
|
+
return []
|
|
18
|
+
|
|
19
|
+
result = subprocess.run(["git", "branch", "-l"], capture_output=True, text=True)
|
|
20
|
+
branches = [line.replace("*","").strip() for line in result.stdout.splitlines()]
|
|
21
|
+
return branches
|
|
22
|
+
|
|
23
|
+
def get_divergence_point(base:str, branch:str) -> str:
|
|
24
|
+
result = subprocess.run(["git", "merge-base", base, branch], capture_output=True, text=True)
|
|
25
|
+
commit_id = result.stdout.strip()
|
|
26
|
+
commit_id = commit_id[:8]
|
|
27
|
+
return commit_id
|
|
28
|
+
|
|
29
|
+
def get_all_changed_files(divergent:str, commit_id:str) -> list[str]:
|
|
30
|
+
result = subprocess.run(["git", "diff", "--name-status", f"{commit_id}..{divergent}"], capture_output=True, text=True)
|
|
31
|
+
lines = [line for line in result.stdout.splitlines() if line.strip()]
|
|
32
|
+
return lines
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def switch_branch(branch: str) -> subprocess.CompletedProcess:
|
|
36
|
+
return subprocess.run(["git", "checkout", branch], capture_output=True, text=True)
|
|
37
|
+
|
|
38
|
+
def checkout_files_from_branch(branch:str, files:list[str]) -> subprocess.CompletedProcess:
|
|
39
|
+
return subprocess.run(["git", "checkout", branch, '--'] + files, capture_output=True, text=True)
|
|
40
|
+
|
|
41
|
+
def stage_files(files: list[str]) -> subprocess.CompletedProcess:
|
|
42
|
+
return subprocess.run(["git", "add"] + files, capture_output=True, text=True)
|
|
43
|
+
|
|
44
|
+
def commit(title: str, description:str = "") -> subprocess.CompletedProcess:
|
|
45
|
+
cmd = ["git", "commit", "-m", title]
|
|
46
|
+
if description.strip():
|
|
47
|
+
cmd += ["-m", description]
|
|
48
|
+
return subprocess.run(cmd, capture_output=True, text=True)
|
|
49
|
+
|
|
50
|
+
def cherry_pick_files(source_branch:str, target_branch:str, files:list[str], commit_title:str, commit_description: str = "") -> tuple[bool, str]:
|
|
51
|
+
result = switch_branch(target_branch)
|
|
52
|
+
if result.returncode != 0:
|
|
53
|
+
return False, f"Failed to switch to {target_branch}: {result.stderr.strip()}"
|
|
54
|
+
result = checkout_files_from_branch(source_branch, files)
|
|
55
|
+
if result.returncode != 0:
|
|
56
|
+
switch_branch(source_branch)
|
|
57
|
+
return False, f"Failed to checkout files from {source_branch}: {result.stderr.strip()}"
|
|
58
|
+
result = stage_files(files)
|
|
59
|
+
if result.returncode != 0:
|
|
60
|
+
switch_branch(source_branch)
|
|
61
|
+
return False, f"Failed to stage files: {result.stderr.strip()}"
|
|
62
|
+
result = commit(commit_title, commit_description)
|
|
63
|
+
if result.returncode != 0:
|
|
64
|
+
switch_branch(source_branch)
|
|
65
|
+
return False, f"Failed to commit:{result.stderr.strip()}"
|
|
66
|
+
switch_branch(source_branch)
|
|
67
|
+
return True, "Cherry pick completed successfully"
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
from textual import on
|
|
2
|
+
from textual.app import ComposeResult
|
|
3
|
+
from textual.containers import Vertical
|
|
4
|
+
from textual.suggester import SuggestFromList
|
|
5
|
+
from textual.widgets import Input, Label, OptionList
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class FilterableOptionPicker(Vertical):
|
|
9
|
+
DEFAULT_CSS = """
|
|
10
|
+
FilterableOptionPicker {
|
|
11
|
+
height: auto;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
.filterable_option_picker_unit {
|
|
15
|
+
height: auto;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/* Bound the list so it scrolls internally instead of growing to
|
|
19
|
+
fit every branch and inflating the row it lives in. */
|
|
20
|
+
FilterableOptionPicker #options {
|
|
21
|
+
height: auto;
|
|
22
|
+
max-height: 8;
|
|
23
|
+
}
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(self, label:str, items: list[str] | None = None, **kwargs):
|
|
27
|
+
super().__init__(**kwargs)
|
|
28
|
+
self.label = label
|
|
29
|
+
self.items = items or []
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def compose(self) -> ComposeResult:
|
|
33
|
+
with Vertical(classes="filterable_option_picker_unit"):
|
|
34
|
+
yield Label(self.label)
|
|
35
|
+
yield Input(suggester=SuggestFromList(self.items, case_sensitive=False), id="search")
|
|
36
|
+
yield OptionList(id="options")
|
|
37
|
+
|
|
38
|
+
def on_mount(self, ) -> None:
|
|
39
|
+
self.sync_input_suggestions()
|
|
40
|
+
self.sync_option_list()
|
|
41
|
+
|
|
42
|
+
def set_items(self, items: list[str]) -> None:
|
|
43
|
+
self.items = items
|
|
44
|
+
self.sync_option_list()
|
|
45
|
+
self.sync_input_suggestions()
|
|
46
|
+
|
|
47
|
+
def sync_option_list(self) -> None:
|
|
48
|
+
options = self.query_one("#options", OptionList)
|
|
49
|
+
options.set_options(self.items)
|
|
50
|
+
options.highlighted = 0 if self.items else None
|
|
51
|
+
|
|
52
|
+
def sync_input_suggestions(self) -> None:
|
|
53
|
+
search = self.query_one("#search", Input)
|
|
54
|
+
search.suggester = SuggestFromList(self.items, case_sensitive=False)
|
|
55
|
+
|
|
56
|
+
def get_search_value(self) -> str:
|
|
57
|
+
return self.query_one("#search", Input).value
|
|
58
|
+
|
|
59
|
+
@on(Input.Changed, "#search")
|
|
60
|
+
def filter(self, event:Input.Changed) -> None:
|
|
61
|
+
text = event.value.strip().lower()
|
|
62
|
+
filtered = self.items if not text else [x for x in self.items if text in x.lower()]
|
|
63
|
+
options = self.query_one("#options", OptionList)
|
|
64
|
+
options.set_options(filtered)
|
|
65
|
+
options.highlighted = 0 if filtered else None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@on(OptionList.OptionSelected, "#options")
|
|
69
|
+
def select(self, event: OptionList.OptionSelected) -> None:
|
|
70
|
+
chosen = str(event.option.prompt)
|
|
71
|
+
search = self.query_one("#search", Input)
|
|
72
|
+
search.value = chosen
|
|
73
|
+
search.focus()
|
|
74
|
+
search.action_end()
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cherry-files-toolkit
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: TUI toolkit for cherry-picking files between git branches
|
|
5
|
+
Project-URL: Repository, https://github.com/NelsonUrrutia/cherry-files-toolkit
|
|
6
|
+
Author-email: Nelson Urrutia <nelurrutia15@gmail.com>
|
|
7
|
+
License: MIT License
|
|
8
|
+
|
|
9
|
+
Copyright (c) 2026 Nelson Urrutia
|
|
10
|
+
|
|
11
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
12
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
13
|
+
in the Software without restriction, including without limitation the rights
|
|
14
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
15
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
16
|
+
furnished to do so, subject to the following conditions:
|
|
17
|
+
|
|
18
|
+
The above copyright notice and this permission notice shall be included in all
|
|
19
|
+
copies or substantial portions of the Software.
|
|
20
|
+
|
|
21
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
22
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
23
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
24
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
25
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
26
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
27
|
+
SOFTWARE.
|
|
28
|
+
License-File: LICENSE
|
|
29
|
+
Requires-Python: >=3.10
|
|
30
|
+
Requires-Dist: textual
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
|
|
33
|
+
# Cherry Files Toolkit
|
|
34
|
+
|
|
35
|
+
> A terminal UI (built with [Textual](https://textual.textualize.io/)) for moving
|
|
36
|
+
> work between git branches at the **file** level — pick the final state of the
|
|
37
|
+
> files you want and apply them to another branch as one clean commit, no
|
|
38
|
+
> commit-by-commit cherry-picking.
|
|
39
|
+
|
|
40
|
+
It ships two tools side by side:
|
|
41
|
+
|
|
42
|
+
- **Cherry Files Picker** — select files from your current branch and commit
|
|
43
|
+
their current content onto a target branch.
|
|
44
|
+
- **Cherry Files Diff** — see every file added, modified, or deleted on a
|
|
45
|
+
branch since it diverged from its base, grouped as a scannable tree.
|
|
46
|
+
|
|
47
|
+
## The idea
|
|
48
|
+
|
|
49
|
+
This project is a new iteration of my previous
|
|
50
|
+
[Cherry Files Picker](https://github.com/NelsonUrrutia/cherry-files-picker).
|
|
51
|
+
The goal is the same — move the final state of selected files between branches
|
|
52
|
+
as one clean commit — but this version upgrades the UI to a full TUI and adds
|
|
53
|
+
a second module, **Cherry Files Diff**, for understanding branch divergence at
|
|
54
|
+
a glance.
|
|
55
|
+
|
|
56
|
+
## Install
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
pip install cherry-files-toolkit
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Requires Python 3.10+ and `git` on your PATH.
|
|
63
|
+
|
|
64
|
+
## Usage
|
|
65
|
+
|
|
66
|
+
Run it from inside any git repository:
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
cherry-files-toolkit
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Press `Ctrl+Q` to quit.
|
|
73
|
+
|
|
74
|
+
### Cherry Files Picker (right panel)
|
|
75
|
+
|
|
76
|
+
Transfers the final approved state of selected files from your working branch
|
|
77
|
+
to another branch:
|
|
78
|
+
|
|
79
|
+
1. **Source branch** is pre-filled with your current branch.
|
|
80
|
+
2. Pick a **target branch** — where the work should land.
|
|
81
|
+
3. Search and select the **files** to move. Selections persist while you
|
|
82
|
+
filter.
|
|
83
|
+
4. Write a **commit title and description**, review the selected files, and
|
|
84
|
+
hit **Cherry Pick Files**. The toolkit commits those files' current
|
|
85
|
+
content onto the target branch in a single commit.
|
|
86
|
+
|
|
87
|
+
Use it when you know which files are done and want them on another branch
|
|
88
|
+
without replaying the messy commit history that produced them.
|
|
89
|
+
|
|
90
|
+
### Cherry Files Diff (left panel)
|
|
91
|
+
|
|
92
|
+
Explains branch divergence at the file level, so you can recover context on a
|
|
93
|
+
branch you don't know well:
|
|
94
|
+
|
|
95
|
+
1. Pick the **divergent branch** (the one with the work) and its **base
|
|
96
|
+
branch**.
|
|
97
|
+
2. **Start Diff Checker** finds the point where they diverged and lists every
|
|
98
|
+
file created, modified, or deleted since — with per-category counts and a
|
|
99
|
+
directory tree for fast scanning.
|
|
100
|
+
|
|
101
|
+
## License
|
|
102
|
+
|
|
103
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
cherry_files_toolkit/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
cherry_files_toolkit/main.py,sha256=6GBuIeHUpVq2u4XwVxodL-VkWmRTCCbrxI5q_qUFbaA,974
|
|
3
|
+
cherry_files_toolkit/modules/__init__.py,sha256=2pwGtfoyl_EXpnjFYrQk67OyEMSAWC4_90LwvVgdwZ0,27
|
|
4
|
+
cherry_files_toolkit/modules/cherry_files_diff.py,sha256=LZRKEMKsrg-iou5NzXkOzbF4ZGmif32Os2yqjgBgsJ4,9050
|
|
5
|
+
cherry_files_toolkit/modules/cherry_files_picker.py,sha256=oYgvMsq92fUGjf_0Q0DhK8OMgEF5PTOIrOEyvD4Sv4Y,8663
|
|
6
|
+
cherry_files_toolkit/utilities/__init__.py,sha256=8c-2vaDTq2X25O5hLJl8OenE8onyGtALOSHP5eGpLBk,49
|
|
7
|
+
cherry_files_toolkit/utilities/git_functions.py,sha256=9AL9kJmqU3WpQ5x_NTAsuf-Xfn-fiGfEXngEMXiMHeg,2985
|
|
8
|
+
cherry_files_toolkit/widgets/__init__.py,sha256=Gctk3ynphsTfNsQwqM7yVf_mij3d6jnNP6LuVo0bx3U,33
|
|
9
|
+
cherry_files_toolkit/widgets/filterable_option_picker.py,sha256=UwYqcrLpS-TLXQ4vssqd03tO81qbbsC3I2eUgGYMb1g,2500
|
|
10
|
+
cherry_files_toolkit-0.1.0.dist-info/METADATA,sha256=OjNqdnD3kVkK6PCdiZsODargXj033SFYGicnLZv_jT8,3953
|
|
11
|
+
cherry_files_toolkit-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
12
|
+
cherry_files_toolkit-0.1.0.dist-info/entry_points.txt,sha256=IGOTge0nlH_71lb1QJLHf5r3FYn6ak4beV0sSrkHYEs,71
|
|
13
|
+
cherry_files_toolkit-0.1.0.dist-info/licenses/LICENSE,sha256=6delUBIrArl5odqkcUbmjKxo3gdAJ2L7lBolWK1Pxv8,1071
|
|
14
|
+
cherry_files_toolkit-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Nelson Urrutia
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|