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.
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,2 @@
1
+ """Textual UI modules."""
2
+
@@ -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,2 @@
1
+ """Utility helpers for cherry-files-toolkit."""
2
+
@@ -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,2 @@
1
+ """Reusable Textual widgets."""
2
+
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ cherry-files-toolkit = cherry_files_toolkit.main:run
@@ -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.