bitp 1.0.7__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.
@@ -0,0 +1,1811 @@
1
+ #
2
+ # Copyright (C) 2025 Bruce Ashfield <bruce.ashfield@gmail.com>
3
+ #
4
+ # SPDX-License-Identifier: GPL-2.0-only
5
+ #
6
+ """
7
+ Core abstractions for bit.
8
+
9
+ This module provides:
10
+ - Colors: ANSI color formatting
11
+ - GitRepo: Git repository operations wrapper
12
+ - FzfMenu: fzf menu builder and runner
13
+ - State management: defaults, prep state, export state
14
+ """
15
+
16
+ import json
17
+ import os
18
+ import shutil
19
+ import subprocess
20
+ from datetime import datetime
21
+ from typing import Callable, Dict, List, Optional, Set, Tuple, Union
22
+
23
+
24
+ class Colors:
25
+ """ANSI color codes for terminal output."""
26
+ GREEN = "\033[32m"
27
+ YELLOW = "\033[33m"
28
+ RED = "\033[31m"
29
+ CYAN = "\033[36m"
30
+ MAGENTA = "\033[35m"
31
+ BOLD = "\033[1m"
32
+ DIM = "\033[2m"
33
+ RESET = "\033[0m"
34
+
35
+ @classmethod
36
+ def green(cls, text: str) -> str:
37
+ return f"{cls.GREEN}{text}{cls.RESET}"
38
+
39
+ @classmethod
40
+ def yellow(cls, text: str) -> str:
41
+ return f"{cls.YELLOW}{text}{cls.RESET}"
42
+
43
+ @classmethod
44
+ def red(cls, text: str) -> str:
45
+ return f"{cls.RED}{text}{cls.RESET}"
46
+
47
+ @classmethod
48
+ def cyan(cls, text: str) -> str:
49
+ return f"{cls.CYAN}{text}{cls.RESET}"
50
+
51
+ @classmethod
52
+ def magenta(cls, text: str) -> str:
53
+ return f"{cls.MAGENTA}{text}{cls.RESET}"
54
+
55
+ @classmethod
56
+ def dim(cls, text: str) -> str:
57
+ return f"{cls.DIM}{text}{cls.RESET}"
58
+
59
+ @classmethod
60
+ def bold(cls, text: str) -> str:
61
+ return f"{cls.BOLD}{text}{cls.RESET}"
62
+
63
+
64
+ # =============================================================================
65
+ # FZF Themes
66
+ # =============================================================================
67
+
68
+ # Theme format: fzf --color option string
69
+ # Themes control: fg+/bg+ (current line), hl/hl+ (match highlight),
70
+ # pointer, marker, prompt, header, info
71
+ # Note: fg (default text) is a separate setting to allow independent control
72
+ FZF_THEMES: Dict[str, Tuple[str, str]] = {
73
+ # (color_string, description)
74
+ "default": (
75
+ "",
76
+ "Terminal default colors"
77
+ ),
78
+ "dark": (
79
+ "fg+:white,bg+:236,hl:yellow,hl+:yellow,pointer:cyan,marker:cyan,prompt:cyan,header:blue",
80
+ "Dark background optimized"
81
+ ),
82
+ "light": (
83
+ "fg+:black,bg+:254,hl:blue,hl+:blue,pointer:magenta,marker:magenta,prompt:magenta,header:blue",
84
+ "Light background optimized"
85
+ ),
86
+ "high-contrast": (
87
+ "fg+:white:bold,bg+:black,hl:yellow:bold,hl+:yellow:bold,pointer:red:bold,marker:red:bold,prompt:green:bold,header:cyan:bold",
88
+ "Maximum visibility"
89
+ ),
90
+ "ocean": (
91
+ "fg+:white,bg+:24,hl:cyan,hl+:cyan:bold,pointer:white,marker:white,prompt:cyan,header:39",
92
+ "Blue ocean tones"
93
+ ),
94
+ "forest": (
95
+ "fg+:white,bg+:22,hl:yellow,hl+:yellow:bold,pointer:white,marker:white,prompt:green,header:34",
96
+ "Green forest tones"
97
+ ),
98
+ "nord": (
99
+ "fg+:white,bg+:60,hl:cyan,hl+:cyan:bold,pointer:cyan,marker:cyan,prompt:blue,header:blue",
100
+ "Nord color scheme"
101
+ ),
102
+ "dracula": (
103
+ "fg+:white,bg+:61,hl:212,hl+:212:bold,pointer:212,marker:212,prompt:141,header:139",
104
+ "Dracula color scheme"
105
+ ),
106
+ }
107
+
108
+ # Text color presets (fg) - separate from themes for independent control
109
+ # Use these if your terminal's default text color doesn't work well
110
+ FZF_TEXT_COLORS: Dict[str, Tuple[str, str]] = {
111
+ # (fg_color_value, description)
112
+ "auto": ("", "Use terminal default"),
113
+ "white": ("white", "White text (for dark backgrounds)"),
114
+ "light-gray": ("252", "Light gray (for dark backgrounds)"),
115
+ "dark-gray": ("235", "Dark gray (for light backgrounds)"),
116
+ "black": ("black", "Black text (for light backgrounds)"),
117
+ "green": ("green", "Green text"),
118
+ "cyan": ("cyan", "Cyan text"),
119
+ "yellow": ("yellow", "Yellow text"),
120
+ }
121
+
122
+ # Individual color elements that can be customized
123
+ # These override theme settings for specific elements
124
+ FZF_COLOR_ELEMENTS: Dict[str, str] = {
125
+ # element_name: description
126
+ "fg": "Default text",
127
+ "fg+": "Current line text",
128
+ "bg+": "Current line background",
129
+ "hl": "Match highlight",
130
+ "hl+": "Match highlight (current line)",
131
+ "pointer": "Pointer arrow",
132
+ "marker": "Multi-select marker",
133
+ "prompt": "Prompt text",
134
+ "header": "Header text",
135
+ "info": "Info line",
136
+ }
137
+
138
+ # Color values available for individual customization
139
+ COLOR_VALUES: Dict[str, str] = {
140
+ # name: fzf color value
141
+ "(default)": "",
142
+ "white": "white",
143
+ "black": "black",
144
+ "red": "red",
145
+ "green": "green",
146
+ "yellow": "yellow",
147
+ "blue": "blue",
148
+ "magenta": "magenta",
149
+ "cyan": "cyan",
150
+ # 256-color values
151
+ "light-gray": "252",
152
+ "dark-gray": "236",
153
+ "light-red": "203",
154
+ "light-green": "114",
155
+ "light-yellow": "228",
156
+ "light-blue": "75",
157
+ "light-magenta": "213",
158
+ "light-cyan": "123",
159
+ "orange": "208",
160
+ "pink": "212",
161
+ "purple": "141",
162
+ "teal": "37",
163
+ }
164
+
165
+ # =============================================================================
166
+ # Terminal Output Colors (configurable)
167
+ # =============================================================================
168
+
169
+ # Elements in terminal output that can have their color customized
170
+ TERMINAL_COLOR_ELEMENTS: Dict[str, Tuple[str, str]] = {
171
+ # element_name: (default_color, description)
172
+ "upstream": ("yellow", "Upstream commit indicator (↓ N to pull)"),
173
+ "local": ("green", "Local commit count"),
174
+ "dirty": ("red", "Dirty working tree indicator"),
175
+ "clean": ("green", "Clean working tree indicator"),
176
+ "repo": ("green", "Repo name (configured layers)"),
177
+ "repo_discovered": ("magenta", "Repo name (discovered layers)"),
178
+ "repo_external": ("cyan", "Repo name (external repos)"),
179
+ "fragment_enabled": ("green", "Enabled fragment in browser"),
180
+ }
181
+
182
+ # ANSI codes for terminal colors
183
+ ANSI_COLORS: Dict[str, str] = {
184
+ "white": "\033[37m",
185
+ "black": "\033[30m",
186
+ "red": "\033[31m",
187
+ "green": "\033[32m",
188
+ "yellow": "\033[33m",
189
+ "blue": "\033[34m",
190
+ "magenta": "\033[35m",
191
+ "cyan": "\033[36m",
192
+ # Bright/light variants (using 256-color codes)
193
+ "light-gray": "\033[38;5;252m",
194
+ "dark-gray": "\033[38;5;236m",
195
+ "light-red": "\033[38;5;203m",
196
+ "light-green": "\033[38;5;114m",
197
+ "light-yellow": "\033[38;5;228m",
198
+ "light-blue": "\033[38;5;75m",
199
+ "light-magenta": "\033[38;5;213m",
200
+ "light-cyan": "\033[38;5;123m",
201
+ "orange": "\033[38;5;208m",
202
+ "pink": "\033[38;5;212m",
203
+ "purple": "\033[38;5;141m",
204
+ "teal": "\033[38;5;37m",
205
+ }
206
+
207
+
208
+ def get_terminal_color(element: str, defaults_file: str = ".bit.defaults") -> str:
209
+ """
210
+ Get configured color for a terminal output element.
211
+
212
+ Args:
213
+ element: Element name from TERMINAL_COLOR_ELEMENTS
214
+ defaults_file: Path to defaults file
215
+
216
+ Returns:
217
+ Color name (e.g., "yellow", "cyan")
218
+ """
219
+ # Get default for this element
220
+ default_color = TERMINAL_COLOR_ELEMENTS.get(element, ("yellow", ""))[0]
221
+
222
+ try:
223
+ if os.path.exists(defaults_file):
224
+ with open(defaults_file, encoding="utf-8") as f:
225
+ data = json.load(f)
226
+ terminal_colors = data.get("terminal_colors", {})
227
+ return terminal_colors.get(element, default_color)
228
+ except (json.JSONDecodeError, OSError):
229
+ pass
230
+ return default_color
231
+
232
+
233
+ def set_terminal_color(element: str, color_name: str, defaults_file: str = ".bit.defaults") -> bool:
234
+ """
235
+ Set color for a terminal output element.
236
+
237
+ Args:
238
+ element: Element name from TERMINAL_COLOR_ELEMENTS
239
+ color_name: Color name from ANSI_COLORS
240
+ defaults_file: Path to defaults file
241
+
242
+ Returns:
243
+ True if successful
244
+ """
245
+ if element not in TERMINAL_COLOR_ELEMENTS:
246
+ return False
247
+ if color_name not in ANSI_COLORS and color_name != "(default)":
248
+ return False
249
+
250
+ try:
251
+ data = {}
252
+ if os.path.exists(defaults_file):
253
+ with open(defaults_file, encoding="utf-8") as f:
254
+ data = json.load(f)
255
+
256
+ if "terminal_colors" not in data:
257
+ data["terminal_colors"] = {}
258
+
259
+ default_color = TERMINAL_COLOR_ELEMENTS[element][0]
260
+ if color_name == "(default)" or color_name == default_color:
261
+ # Remove custom setting, use default
262
+ data["terminal_colors"].pop(element, None)
263
+ else:
264
+ data["terminal_colors"][element] = color_name
265
+
266
+ # Clean up empty dict
267
+ if not data["terminal_colors"]:
268
+ del data["terminal_colors"]
269
+
270
+ with open(defaults_file, "w", encoding="utf-8") as f:
271
+ json.dump(data, f, indent=2)
272
+ return True
273
+ except (OSError, json.JSONDecodeError):
274
+ return False
275
+
276
+
277
+ def terminal_color(element: str, text: str, defaults_file: str = ".bit.defaults") -> str:
278
+ """
279
+ Apply configured terminal color to text.
280
+
281
+ Args:
282
+ element: Element name from TERMINAL_COLOR_ELEMENTS
283
+ text: Text to colorize
284
+ defaults_file: Path to defaults file
285
+
286
+ Returns:
287
+ Colorized text string with ANSI codes
288
+ """
289
+ color_name = get_terminal_color(element, defaults_file)
290
+ ansi_code = ANSI_COLORS.get(color_name, ANSI_COLORS.get("yellow", "\033[33m"))
291
+ return f"{ansi_code}{text}\033[0m"
292
+
293
+
294
+ def get_fzf_theme(defaults_file: str = ".bit.defaults") -> str:
295
+ """
296
+ Get the current fzf theme color string from defaults.
297
+
298
+ Returns:
299
+ fzf --color option string, or empty string for default.
300
+ """
301
+ try:
302
+ if os.path.exists(defaults_file):
303
+ with open(defaults_file, encoding="utf-8") as f:
304
+ data = json.load(f)
305
+ theme_name = data.get("fzf_theme", "default")
306
+ if theme_name in FZF_THEMES:
307
+ return FZF_THEMES[theme_name][0]
308
+ except (json.JSONDecodeError, OSError):
309
+ pass
310
+ return ""
311
+
312
+
313
+ def get_fzf_text_color(defaults_file: str = ".bit.defaults") -> str:
314
+ """
315
+ Get the current fzf text color (fg) setting from defaults.
316
+
317
+ Returns:
318
+ fg color value (e.g., "white", "252") or empty string for auto/default.
319
+ """
320
+ try:
321
+ if os.path.exists(defaults_file):
322
+ with open(defaults_file, encoding="utf-8") as f:
323
+ data = json.load(f)
324
+ color_name = data.get("fzf_text_color", "auto")
325
+ if color_name in FZF_TEXT_COLORS:
326
+ return FZF_TEXT_COLORS[color_name][0]
327
+ except (json.JSONDecodeError, OSError):
328
+ pass
329
+ return ""
330
+
331
+
332
+ def set_fzf_text_color(color_name: str, defaults_file: str = ".bit.defaults") -> bool:
333
+ """
334
+ Save fzf text color setting to defaults file.
335
+
336
+ Returns:
337
+ True if successful, False otherwise.
338
+ """
339
+ if color_name not in FZF_TEXT_COLORS:
340
+ return False
341
+
342
+ try:
343
+ data = {}
344
+ if os.path.exists(defaults_file):
345
+ with open(defaults_file, encoding="utf-8") as f:
346
+ data = json.load(f)
347
+
348
+ data["fzf_text_color"] = color_name
349
+
350
+ with open(defaults_file, "w", encoding="utf-8") as f:
351
+ json.dump(data, f, indent=2)
352
+ return True
353
+ except (json.JSONDecodeError, OSError):
354
+ return False
355
+
356
+
357
+ def get_current_text_color_name(defaults_file: str = ".bit.defaults") -> str:
358
+ """Get the current text color setting name (not the value)."""
359
+ try:
360
+ if os.path.exists(defaults_file):
361
+ with open(defaults_file, encoding="utf-8") as f:
362
+ data = json.load(f)
363
+ return data.get("fzf_text_color", "auto")
364
+ except (json.JSONDecodeError, OSError):
365
+ pass
366
+ return "auto"
367
+
368
+
369
+ def get_custom_colors(defaults_file: str = ".bit.defaults") -> Dict[str, str]:
370
+ """
371
+ Get individual color overrides from defaults.
372
+
373
+ Returns:
374
+ Dict mapping element names to color values, e.g. {"pointer": "green", "header": "white"}
375
+ """
376
+ try:
377
+ if os.path.exists(defaults_file):
378
+ with open(defaults_file, encoding="utf-8") as f:
379
+ data = json.load(f)
380
+ return data.get("fzf_custom_colors", {})
381
+ except (json.JSONDecodeError, OSError):
382
+ pass
383
+ return {}
384
+
385
+
386
+ def set_custom_color(element: str, color_name: str, defaults_file: str = ".bit.defaults") -> bool:
387
+ """
388
+ Set an individual color override.
389
+
390
+ Args:
391
+ element: Color element (e.g., "pointer", "header")
392
+ color_name: Color name from COLOR_VALUES (e.g., "green", "(default)")
393
+
394
+ Returns:
395
+ True if successful
396
+ """
397
+ if element not in FZF_COLOR_ELEMENTS:
398
+ return False
399
+ if color_name not in COLOR_VALUES:
400
+ return False
401
+
402
+ try:
403
+ data = {}
404
+ if os.path.exists(defaults_file):
405
+ with open(defaults_file, encoding="utf-8") as f:
406
+ data = json.load(f)
407
+
408
+ if "fzf_custom_colors" not in data:
409
+ data["fzf_custom_colors"] = {}
410
+
411
+ if color_name == "(default)":
412
+ # Remove the override
413
+ data["fzf_custom_colors"].pop(element, None)
414
+ else:
415
+ data["fzf_custom_colors"][element] = color_name
416
+
417
+ # Clean up empty dict
418
+ if not data["fzf_custom_colors"]:
419
+ del data["fzf_custom_colors"]
420
+
421
+ with open(defaults_file, "w", encoding="utf-8") as f:
422
+ json.dump(data, f, indent=2)
423
+ return True
424
+ except (json.JSONDecodeError, OSError):
425
+ return False
426
+
427
+
428
+ def clear_custom_colors(defaults_file: str = ".bit.defaults") -> bool:
429
+ """Clear all individual color overrides."""
430
+ try:
431
+ data = {}
432
+ if os.path.exists(defaults_file):
433
+ with open(defaults_file, encoding="utf-8") as f:
434
+ data = json.load(f)
435
+
436
+ data.pop("fzf_custom_colors", None)
437
+
438
+ with open(defaults_file, "w", encoding="utf-8") as f:
439
+ json.dump(data, f, indent=2)
440
+ return True
441
+ except (json.JSONDecodeError, OSError):
442
+ return False
443
+
444
+
445
+ def get_fzf_color_args(defaults_file: str = ".bit.defaults") -> List[str]:
446
+ """
447
+ Get fzf --color arguments as a list for subprocess calls.
448
+
449
+ Combines theme colors with individual overrides (custom colors take precedence).
450
+
451
+ Returns:
452
+ List like ["--color", "fg:white,fg+:white,bg+:blue,..."] or empty list for default.
453
+ """
454
+ theme_colors = get_fzf_theme(defaults_file)
455
+ text_color = get_fzf_text_color(defaults_file)
456
+ custom_colors = get_custom_colors(defaults_file)
457
+
458
+ # Build combined color string
459
+ color_parts = []
460
+
461
+ # 1. Theme colors (base)
462
+ if theme_colors:
463
+ color_parts.append(theme_colors)
464
+
465
+ # 2. Text color override (fg from separate setting)
466
+ if text_color:
467
+ color_parts.append(f"fg:{text_color}")
468
+
469
+ # 3. Individual custom color overrides (highest precedence)
470
+ for element, color_name in custom_colors.items():
471
+ if color_name in COLOR_VALUES and COLOR_VALUES[color_name]:
472
+ color_parts.append(f"{element}:{COLOR_VALUES[color_name]}")
473
+
474
+ if color_parts:
475
+ return ["--color", ",".join(color_parts)]
476
+ return []
477
+
478
+
479
+ def get_fzf_preview_resize_bindings(start_size: int = 50) -> List[str]:
480
+ """
481
+ Generate fzf keybindings for preview window resize with coordinated state.
482
+
483
+ Uses fzf's --listen HTTP API to send resize commands. State is tracked
484
+ in a temp file so alt-right and alt-left coordinate properly.
485
+
486
+ Args:
487
+ start_size: Initial preview window percentage (default 50)
488
+
489
+ Returns:
490
+ List of fzf arguments including --listen and resize bindings
491
+ """
492
+ # Check if curl is available for HTTP API approach
493
+ if not shutil.which("curl"):
494
+ # Fallback to simple cycling if no curl
495
+ return [
496
+ "--bind", "alt-p:change-preview-window(50%|40%|30%|20%|10%|hidden|90%|80%|70%|60%)",
497
+ ]
498
+
499
+ # Generate a unique port based on PID + random to avoid collisions
500
+ import random
501
+ port = 10000 + (os.getpid() % 10000) + random.randint(0, 999)
502
+
503
+ # State file unique to this fzf instance
504
+ state_file = f"/tmp/fzf-pw-{port}"
505
+
506
+ # Initialize state file with starting size
507
+ try:
508
+ with open(state_file, "w") as f:
509
+ f.write(str(start_size))
510
+ except OSError:
511
+ # Fall back to simple cycling if we can't write state
512
+ return [
513
+ "--bind", "alt-p:change-preview-window(50%|40%|30%|20%|10%|hidden|90%|80%|70%|60%)",
514
+ ]
515
+
516
+ # Inline bash commands for resize (no external script needed)
517
+ # Reads state, adjusts, writes back, posts to fzf HTTP API
518
+ shrink_cmd = (
519
+ f"s=$(cat {state_file} 2>/dev/null||echo 50);"
520
+ f"s=$((s-10));[ $s -lt 10 ]&&s=10;"
521
+ f"echo $s>{state_file};"
522
+ f"curl -s -XPOST localhost:{port} -d change-preview-window:$s% >/dev/null 2>&1"
523
+ )
524
+ grow_cmd = (
525
+ f"s=$(cat {state_file} 2>/dev/null||echo 50);"
526
+ f"s=$((s+10));[ $s -gt 90 ]&&s=90;"
527
+ f"echo $s>{state_file};"
528
+ f"curl -s -XPOST localhost:{port} -d change-preview-window:$s% >/dev/null 2>&1"
529
+ )
530
+
531
+ return [
532
+ "--listen", str(port),
533
+ "--bind", f"alt-right:execute-silent({shrink_cmd})",
534
+ "--bind", f"alt-left:execute-silent({grow_cmd})",
535
+ ]
536
+
537
+
538
+ def run_fzf(
539
+ args: List[str],
540
+ input_text: Optional[str] = None,
541
+ defaults_file: str = ".bit.defaults",
542
+ ) -> subprocess.CompletedProcess:
543
+ """
544
+ Run fzf with automatic theme color support.
545
+
546
+ This is the preferred way to run fzf - it automatically applies
547
+ the user's configured theme colors.
548
+
549
+ Args:
550
+ args: fzf arguments (should NOT include "fzf" as first element,
551
+ but if it does, it will be handled)
552
+ input_text: Optional text to pass to fzf's stdin
553
+ defaults_file: Path to defaults file for theme lookup
554
+
555
+ Returns:
556
+ subprocess.CompletedProcess with stdout captured as text.
557
+
558
+ Raises:
559
+ FileNotFoundError: If fzf is not installed.
560
+
561
+ Example:
562
+ result = run_fzf(
563
+ ["--no-multi", "--height", "50%", "--header", "Pick one"],
564
+ input_text="option1\\noption2\\noption3"
565
+ )
566
+ if result.returncode == 0:
567
+ selected = result.stdout.strip()
568
+ """
569
+ # Normalize args - ensure "fzf" is first
570
+ if args and args[0] != "fzf":
571
+ args = ["fzf"] + args
572
+ elif not args:
573
+ args = ["fzf"]
574
+
575
+ # Add theme colors
576
+ color_args = get_fzf_color_args(defaults_file)
577
+ if color_args:
578
+ args = args + color_args
579
+
580
+ return subprocess.run(
581
+ args,
582
+ input=input_text,
583
+ stdout=subprocess.PIPE,
584
+ text=True,
585
+ )
586
+
587
+
588
+ def set_fzf_theme(theme_name: str, defaults_file: str = ".bit.defaults") -> bool:
589
+ """
590
+ Save fzf theme to defaults file.
591
+
592
+ Args:
593
+ theme_name: Name of theme from FZF_THEMES
594
+ defaults_file: Path to defaults file
595
+
596
+ Returns:
597
+ True if saved successfully
598
+ """
599
+ if theme_name not in FZF_THEMES:
600
+ return False
601
+
602
+ try:
603
+ data = {}
604
+ if os.path.exists(defaults_file):
605
+ with open(defaults_file, encoding="utf-8") as f:
606
+ data = json.load(f)
607
+
608
+ data["fzf_theme"] = theme_name
609
+
610
+ with open(defaults_file, "w", encoding="utf-8") as f:
611
+ json.dump(data, f, indent=2)
612
+ return True
613
+ except (json.JSONDecodeError, OSError):
614
+ return False
615
+
616
+
617
+ def get_current_theme_name(defaults_file: str = ".bit.defaults") -> str:
618
+ """Get the name of the currently selected theme."""
619
+ try:
620
+ if os.path.exists(defaults_file):
621
+ with open(defaults_file, encoding="utf-8") as f:
622
+ data = json.load(f)
623
+ theme_name = data.get("fzf_theme", "default")
624
+ if theme_name in FZF_THEMES:
625
+ return theme_name
626
+ except (json.JSONDecodeError, OSError):
627
+ pass
628
+ return "default"
629
+
630
+
631
+ def fzf_theme_picker(defaults_file: str = ".bit.defaults") -> Optional[str]:
632
+ """
633
+ Show fzf menu to select a color theme.
634
+
635
+ The menu shows a live preview of each theme's colors and loops
636
+ so the user can see the effect before exiting.
637
+
638
+ Args:
639
+ defaults_file: Path to defaults file for saving selection
640
+
641
+ Returns:
642
+ Selected theme name, or None if cancelled.
643
+ """
644
+ if not fzf_available():
645
+ return None
646
+
647
+ last_selected = None
648
+
649
+ while True:
650
+ current_theme = get_current_theme_name(defaults_file)
651
+
652
+ # Build menu with theme previews
653
+ menu_lines = []
654
+ for name, (colors, description) in FZF_THEMES.items():
655
+ marker = "● " if name == current_theme else " "
656
+ # Color the theme name based on what it would look like
657
+ colored_name = f"\033[36m{name:<15}\033[0m"
658
+ menu_lines.append(f"{name}\t{marker}{colored_name} {description}")
659
+
660
+ menu_input = "\n".join(menu_lines)
661
+
662
+ # Build preview content showing all elements with current colors
663
+ preview_content = _build_color_preview(defaults_file)
664
+ preview_escaped = preview_content.replace("'", "'\\''")
665
+ preview_cmd = f"echo '{preview_escaped}'"
666
+
667
+ # Build fzf command
668
+ fzf_args = [
669
+ "fzf",
670
+ "--no-multi",
671
+ "--no-sort",
672
+ "--no-info",
673
+ "--ansi",
674
+ "--height", "20",
675
+ "--layout", "reverse",
676
+ "--header", "Select theme (Enter to apply, ←/q=back)",
677
+ "--prompt", "Theme: ",
678
+ "--with-nth", "2..",
679
+ "--delimiter", "\t",
680
+ "--bind", "q:abort",
681
+ "--bind", "left:abort",
682
+ "--preview", preview_cmd,
683
+ "--preview-window", "right:60%:noborder",
684
+ ]
685
+
686
+ # Apply preview resize and theme colors
687
+ fzf_args.extend(get_fzf_preview_resize_bindings())
688
+ fzf_args.extend(get_fzf_color_args(defaults_file))
689
+
690
+ try:
691
+ result = subprocess.run(
692
+ fzf_args,
693
+ input=menu_input,
694
+ stdout=subprocess.PIPE,
695
+ text=True,
696
+ )
697
+ except FileNotFoundError:
698
+ return last_selected
699
+
700
+ if result.returncode != 0 or not result.stdout.strip():
701
+ return last_selected
702
+
703
+ selected = result.stdout.strip().split("\t")[0]
704
+
705
+ if selected in FZF_THEMES:
706
+ set_fzf_theme(selected, defaults_file)
707
+ last_selected = selected
708
+ # Loop to show updated preview
709
+
710
+
711
+ def fzf_text_color_picker(defaults_file: str = ".bit.defaults") -> Optional[str]:
712
+ """
713
+ Show fzf menu to select text color (fg).
714
+
715
+ Args:
716
+ defaults_file: Path to defaults file for saving selection
717
+
718
+ Returns:
719
+ Selected color name, or None if cancelled.
720
+ """
721
+ if not fzf_available():
722
+ return None
723
+
724
+ current_color = get_current_text_color_name(defaults_file)
725
+
726
+ # Build menu
727
+ menu_lines = []
728
+ for name, (color_value, description) in FZF_TEXT_COLORS.items():
729
+ marker = "● " if name == current_color else " "
730
+ # Show a sample of the color if it's set
731
+ if color_value:
732
+ sample = f"\033[38;5;{color_value}m■■■\033[0m" if color_value.isdigit() else f"\033[{'37' if color_value == 'white' else '30' if color_value == 'black' else '32' if color_value == 'green' else '36' if color_value == 'cyan' else '33' if color_value == 'yellow' else '37'}m■■■\033[0m"
733
+ else:
734
+ sample = " "
735
+ menu_lines.append(f"{name}\t{marker}{name:<12} {sample} {description}")
736
+
737
+ menu_input = "\n".join(menu_lines)
738
+
739
+ fzf_args = [
740
+ "fzf",
741
+ "--no-multi",
742
+ "--no-sort",
743
+ "--no-info",
744
+ "--ansi",
745
+ "--height", "~50%",
746
+ "--header", "Select text color (Enter to apply, q to cancel)",
747
+ "--prompt", "Color: ",
748
+ "--with-nth", "2..",
749
+ "--delimiter", "\t",
750
+ "--bind", "q:abort",
751
+ ]
752
+
753
+ # Apply current colors
754
+ fzf_args.extend(get_fzf_color_args(defaults_file))
755
+
756
+ try:
757
+ result = subprocess.run(
758
+ fzf_args,
759
+ input=menu_input,
760
+ stdout=subprocess.PIPE,
761
+ text=True,
762
+ )
763
+ except FileNotFoundError:
764
+ return None
765
+
766
+ if result.returncode != 0 or not result.stdout.strip():
767
+ return None
768
+
769
+ selected = result.stdout.strip().split("\t")[0]
770
+
771
+ if selected in FZF_TEXT_COLORS:
772
+ set_fzf_text_color(selected, defaults_file)
773
+ return selected
774
+
775
+ return None
776
+
777
+
778
+ def _build_color_preview(defaults_file: str) -> str:
779
+ """Build a preview showing all color elements with current settings."""
780
+ # Get current effective colors (theme + custom overrides)
781
+ theme_colors = get_fzf_theme(defaults_file)
782
+ custom_colors = get_custom_colors(defaults_file)
783
+
784
+ # Parse theme colors into a dict
785
+ effective = {}
786
+ if theme_colors:
787
+ for part in theme_colors.split(","):
788
+ if ":" in part:
789
+ key, val = part.split(":", 1)
790
+ effective[key] = val
791
+
792
+ # Apply custom overrides
793
+ for element, color_name in custom_colors.items():
794
+ if color_name in COLOR_VALUES and COLOR_VALUES[color_name]:
795
+ effective[element] = COLOR_VALUES[color_name]
796
+
797
+ def ansi(color_val: str, text: str) -> str:
798
+ """Wrap text in ANSI color codes."""
799
+ if not color_val:
800
+ return text
801
+ fg_codes = {"white": "37", "black": "30", "red": "31", "green": "32",
802
+ "yellow": "33", "blue": "34", "magenta": "35", "cyan": "36"}
803
+ if color_val in fg_codes:
804
+ return f"\033[{fg_codes[color_val]}m{text}\033[0m"
805
+ elif color_val.isdigit():
806
+ return f"\033[38;5;{color_val}m{text}\033[0m"
807
+ return text
808
+
809
+ def combo(fg_val: str, bg_val: str, text: str) -> str:
810
+ """Combine fg and bg colors."""
811
+ fg_codes = {"white": "37", "black": "30", "red": "31", "green": "32",
812
+ "yellow": "33", "blue": "34", "magenta": "35", "cyan": "36"}
813
+ bg_codes = {"white": "47", "black": "40", "red": "41", "green": "42",
814
+ "yellow": "43", "blue": "44", "magenta": "45", "cyan": "46"}
815
+ codes = []
816
+ if fg_val:
817
+ if fg_val in fg_codes:
818
+ codes.append(fg_codes[fg_val])
819
+ elif fg_val.isdigit():
820
+ codes.append(f"38;5;{fg_val}")
821
+ if bg_val:
822
+ if bg_val in bg_codes:
823
+ codes.append(bg_codes[bg_val])
824
+ elif bg_val.isdigit():
825
+ codes.append(f"48;5;{bg_val}")
826
+ if codes:
827
+ return f"\033[{';'.join(codes)}m{text}\033[0m"
828
+ return text
829
+
830
+ W = 28 # Inner content width
831
+
832
+ def pad(text: str, visible_len: int) -> str:
833
+ """Pad text to fill the box width."""
834
+ return text + " " * (W - visible_len)
835
+
836
+ def row(content: str, visible_len: int) -> str:
837
+ """Create a box row."""
838
+ return f"│ {pad(content, visible_len)} │"
839
+
840
+ # Build preview lines
841
+ lines = ["┌" + "─" * W + "──┐"]
842
+
843
+ # Pointer and marker
844
+ ptr = effective.get("pointer", "")
845
+ mkr = effective.get("marker", "")
846
+ lines.append(row(f"{ansi(ptr, '>')} item one", 10))
847
+ lines.append(row(f"{ansi(mkr, '●')} item two (marked)", 19))
848
+ lines.append(row("", 0))
849
+
850
+ # Prompt
851
+ pmt = effective.get("prompt", "")
852
+ lines.append(row(f"{ansi(pmt, 'prompt:')} type here", 17))
853
+
854
+ # Header
855
+ hdr = effective.get("header", "")
856
+ lines.append(row(ansi(hdr, "Header: ? for help"), 18))
857
+ lines.append(row("", 0))
858
+
859
+ # Normal text (fg)
860
+ fg = effective.get("fg", "")
861
+ lines.append(row(ansi(fg, "Normal text (fg)"), 16))
862
+
863
+ # Highlight
864
+ hl = effective.get("hl", "")
865
+ lines.append(row(f"Search {ansi(hl, 'match')} here", 17))
866
+ lines.append(row("", 0))
867
+
868
+ # Selected line (fg+ on bg+)
869
+ fg_plus = effective.get("fg+", "")
870
+ bg_plus = effective.get("bg+", "")
871
+ hl_plus = effective.get("hl+", "")
872
+ sel_text = f"Selected {ansi(hl_plus, 'match')} line"
873
+ sel_padded = pad(sel_text, 19)
874
+ lines.append(f"│ {combo(fg_plus, bg_plus, sel_padded)} │")
875
+
876
+ # Info
877
+ inf = effective.get("info", "")
878
+ lines.append(row(ansi(inf, "info: 5/10"), 10))
879
+
880
+ lines.append("└" + "─" * W + "──┘")
881
+
882
+ return "\n".join(lines)
883
+
884
+
885
+ def fzf_custom_color_menu(defaults_file: str = ".bit.defaults") -> None:
886
+ """
887
+ Show fzf menu to customize individual color elements.
888
+
889
+ Presents a list of color elements (pointer, header, etc.) and lets
890
+ user pick which one to customize.
891
+ """
892
+ if not fzf_available():
893
+ return
894
+
895
+ while True:
896
+ custom_colors = get_custom_colors(defaults_file)
897
+ override_count = len(custom_colors)
898
+
899
+ # Build menu of elements
900
+ menu_lines = []
901
+ for element, description in FZF_COLOR_ELEMENTS.items():
902
+ current = custom_colors.get(element, "(default)")
903
+ # Show color sample
904
+ if current != "(default)" and current in COLOR_VALUES:
905
+ color_val = COLOR_VALUES[current]
906
+ if color_val:
907
+ sample = _color_sample(color_val)
908
+ else:
909
+ sample = " "
910
+ else:
911
+ sample = " "
912
+ menu_lines.append(f"{element}\t{element:<10} {sample} {current:<14} {description}")
913
+
914
+ # Add separator and reset option
915
+ menu_lines.append(f"---\t{'─' * 50}")
916
+ if override_count > 0:
917
+ menu_lines.append(f"CLEAR\t✖ Reset all ({override_count} override{'s' if override_count != 1 else ''}) — restore theme defaults")
918
+ else:
919
+ menu_lines.append(f"CLEAR\t (no overrides)")
920
+
921
+ menu_input = "\n".join(menu_lines)
922
+
923
+ # Build preview content showing all elements with current colors
924
+ preview_content = _build_color_preview(defaults_file)
925
+ # Escape for shell
926
+ preview_escaped = preview_content.replace("'", "'\\''")
927
+ preview_cmd = f"echo '{preview_escaped}'"
928
+
929
+ fzf_args = [
930
+ "fzf",
931
+ "--no-multi",
932
+ "--no-sort",
933
+ "--no-info",
934
+ "--ansi",
935
+ "--height", "20",
936
+ "--layout", "reverse",
937
+ "--header", "Individual Colors (Enter=edit, ←/q=back)",
938
+ "--prompt", "Element: ",
939
+ "--with-nth", "2..",
940
+ "--delimiter", "\t",
941
+ "--bind", "q:abort",
942
+ "--bind", "left:abort",
943
+ "--preview", preview_cmd,
944
+ "--preview-window", "right:60%:noborder",
945
+ ]
946
+
947
+ fzf_args.extend(get_fzf_preview_resize_bindings())
948
+ fzf_args.extend(get_fzf_color_args(defaults_file))
949
+
950
+ try:
951
+ result = subprocess.run(
952
+ fzf_args,
953
+ input=menu_input,
954
+ stdout=subprocess.PIPE,
955
+ text=True,
956
+ )
957
+ except FileNotFoundError:
958
+ return
959
+
960
+ if result.returncode != 0 or not result.stdout.strip():
961
+ return
962
+
963
+ selected = result.stdout.strip().split("\t")[0]
964
+
965
+ if selected == "CLEAR":
966
+ clear_custom_colors(defaults_file)
967
+ elif selected == "---":
968
+ pass # Separator, do nothing
969
+ elif selected in FZF_COLOR_ELEMENTS:
970
+ _pick_color_for_element(selected, defaults_file)
971
+
972
+
973
+ def _color_sample(color_val: str) -> str:
974
+ """Generate ANSI color sample for a color value."""
975
+ if not color_val:
976
+ return " "
977
+ # Map named colors to ANSI codes
978
+ named_colors = {
979
+ "white": "37", "black": "30", "red": "31", "green": "32",
980
+ "yellow": "33", "blue": "34", "magenta": "35", "cyan": "36",
981
+ }
982
+ if color_val in named_colors:
983
+ return f"\033[{named_colors[color_val]}m■■■\033[0m"
984
+ elif color_val.isdigit():
985
+ return f"\033[38;5;{color_val}m■■■\033[0m"
986
+ return "■■■"
987
+
988
+
989
+ def _pick_color_for_element(element: str, defaults_file: str) -> Optional[str]:
990
+ """Show color picker for a specific element."""
991
+ if not fzf_available():
992
+ return None
993
+
994
+ custom_colors = get_custom_colors(defaults_file)
995
+ current = custom_colors.get(element, "(default)")
996
+
997
+ # Build menu of colors
998
+ menu_lines = []
999
+ for name, value in COLOR_VALUES.items():
1000
+ marker = "● " if name == current else " "
1001
+ sample = _color_sample(value) if value else " "
1002
+ menu_lines.append(f"{name}\t{marker}{name:<14} {sample}")
1003
+
1004
+ menu_input = "\n".join(menu_lines)
1005
+
1006
+ # Build preview showing what changing this element would look like
1007
+ preview_content = _build_color_preview(defaults_file)
1008
+ preview_escaped = preview_content.replace("'", "'\\''")
1009
+ preview_cmd = f"echo '{preview_escaped}'"
1010
+
1011
+ desc = FZF_COLOR_ELEMENTS.get(element, element)
1012
+ fzf_args = [
1013
+ "fzf",
1014
+ "--no-multi",
1015
+ "--no-sort",
1016
+ "--no-info",
1017
+ "--ansi",
1018
+ "--height", "20",
1019
+ "--layout", "reverse",
1020
+ "--header", f"Select color for {element} ({desc}) ←/q=back",
1021
+ "--prompt", "Color: ",
1022
+ "--with-nth", "2..",
1023
+ "--delimiter", "\t",
1024
+ "--bind", "q:abort",
1025
+ "--bind", "left:abort",
1026
+ "--preview", preview_cmd,
1027
+ "--preview-window", "right:60%:noborder",
1028
+ ]
1029
+
1030
+ fzf_args.extend(get_fzf_preview_resize_bindings())
1031
+ fzf_args.extend(get_fzf_color_args(defaults_file))
1032
+
1033
+ try:
1034
+ result = subprocess.run(
1035
+ fzf_args,
1036
+ input=menu_input,
1037
+ stdout=subprocess.PIPE,
1038
+ text=True,
1039
+ )
1040
+ except FileNotFoundError:
1041
+ return None
1042
+
1043
+ if result.returncode != 0 or not result.stdout.strip():
1044
+ return None
1045
+
1046
+ selected = result.stdout.strip().split("\t")[0]
1047
+
1048
+ if selected in COLOR_VALUES:
1049
+ set_custom_color(element, selected, defaults_file)
1050
+ return selected
1051
+
1052
+ return None
1053
+
1054
+
1055
+ class GitRepo:
1056
+ """
1057
+ Git repository wrapper providing consistent interface for git operations.
1058
+
1059
+ Usage:
1060
+ repo = GitRepo("/path/to/repo")
1061
+ branch = repo.current_branch()
1062
+ if repo.is_clean():
1063
+ repo.run(["pull", "--rebase"])
1064
+ """
1065
+
1066
+ def __init__(self, path: str):
1067
+ """Initialize with repository path."""
1068
+ self.path = path
1069
+
1070
+ def check_output(self, cmd: List[str], **kwargs) -> str:
1071
+ """
1072
+ Run git command and return output.
1073
+
1074
+ Args:
1075
+ cmd: Git command arguments (without 'git' and '-C path')
1076
+ **kwargs: Additional arguments for subprocess.check_output
1077
+
1078
+ Returns:
1079
+ Command output as string (stripped)
1080
+ """
1081
+ kwargs.setdefault('text', True)
1082
+ kwargs.setdefault('stderr', subprocess.DEVNULL)
1083
+ result = subprocess.check_output(
1084
+ ["git", "-C", self.path] + cmd,
1085
+ **kwargs
1086
+ )
1087
+ return result.strip() if isinstance(result, str) else result
1088
+
1089
+ def run(self, cmd: List[str], check: bool = True, **kwargs) -> subprocess.CompletedProcess:
1090
+ """
1091
+ Run git command and return CompletedProcess.
1092
+
1093
+ Args:
1094
+ cmd: Git command arguments (without 'git' and '-C path')
1095
+ check: Raise on non-zero exit (default True)
1096
+ **kwargs: Additional arguments for subprocess.run
1097
+
1098
+ Returns:
1099
+ CompletedProcess instance
1100
+ """
1101
+ kwargs.setdefault('text', True)
1102
+ return subprocess.run(
1103
+ ["git", "-C", self.path] + cmd,
1104
+ check=check,
1105
+ **kwargs
1106
+ )
1107
+
1108
+ def toplevel(self) -> Optional[str]:
1109
+ """Get repository root path, resolved to canonical form."""
1110
+ try:
1111
+ out = self.check_output(["rev-parse", "--show-toplevel"])
1112
+ return os.path.realpath(out)
1113
+ except subprocess.CalledProcessError:
1114
+ return None
1115
+
1116
+ def current_branch(self) -> Optional[str]:
1117
+ """Get current branch name, or None if detached."""
1118
+ try:
1119
+ return self.check_output(["symbolic-ref", "--short", "HEAD"])
1120
+ except subprocess.CalledProcessError:
1121
+ return None
1122
+
1123
+ def current_head(self) -> Optional[str]:
1124
+ """Get current HEAD commit SHA."""
1125
+ try:
1126
+ return self.check_output(["rev-parse", "HEAD"])
1127
+ except subprocess.CalledProcessError:
1128
+ return None
1129
+
1130
+ def is_clean(self) -> bool:
1131
+ """Check if repo has no uncommitted changes to tracked files."""
1132
+ try:
1133
+ result = self.run(
1134
+ ["status", "--porcelain", "-uno"],
1135
+ check=False,
1136
+ capture_output=True
1137
+ )
1138
+ return result.returncode == 0 and not result.stdout.strip()
1139
+ except subprocess.CalledProcessError:
1140
+ return False
1141
+
1142
+ def origin_url(self) -> Optional[str]:
1143
+ """Get origin remote URL."""
1144
+ try:
1145
+ return self.check_output(["remote", "get-url", "origin"])
1146
+ except subprocess.CalledProcessError:
1147
+ return None
1148
+
1149
+ def config_get(self, key: str) -> Optional[str]:
1150
+ """Get git config value."""
1151
+ try:
1152
+ return self.check_output(["config", "--get", key])
1153
+ except subprocess.CalledProcessError:
1154
+ return None
1155
+
1156
+ def config_set(self, key: str, value: str) -> bool:
1157
+ """Set git config value. Returns True on success."""
1158
+ try:
1159
+ self.run(["config", key, value], capture_output=True)
1160
+ return True
1161
+ except subprocess.CalledProcessError:
1162
+ return False
1163
+
1164
+ def rev_list_count(self, range_spec: str) -> int:
1165
+ """Count commits in range."""
1166
+ try:
1167
+ out = self.check_output(["rev-list", "--count", range_spec])
1168
+ return int(out)
1169
+ except (subprocess.CalledProcessError, ValueError):
1170
+ return 0
1171
+
1172
+ def log(self, args: List[str]) -> str:
1173
+ """Run git log with given arguments."""
1174
+ try:
1175
+ return self.check_output(["log"] + args)
1176
+ except subprocess.CalledProcessError:
1177
+ return ""
1178
+
1179
+ def diff_stat(self, range_spec: str) -> str:
1180
+ """Get diff --stat for range."""
1181
+ try:
1182
+ return self.check_output(["diff", "--stat", range_spec])
1183
+ except subprocess.CalledProcessError:
1184
+ return ""
1185
+
1186
+
1187
+ class FzfMenu:
1188
+ """
1189
+ Builder for fzf menus.
1190
+
1191
+ Usage:
1192
+ menu = FzfMenu(header="Select an option", prompt="Choice: ")
1193
+ menu.add_option("opt1", "Option One", "Description of option one")
1194
+ menu.add_option("opt2", "Option Two", "Description of option two")
1195
+ menu.add_bind("ctrl-a", "select-all")
1196
+ selected = menu.show()
1197
+ """
1198
+
1199
+ def __init__(
1200
+ self,
1201
+ header: str = "",
1202
+ prompt: str = "> ",
1203
+ multi: bool = False,
1204
+ height: str = "~50%",
1205
+ ansi: bool = True,
1206
+ no_sort: bool = False,
1207
+ ):
1208
+ """
1209
+ Initialize menu builder.
1210
+
1211
+ Args:
1212
+ header: Header text shown above menu
1213
+ prompt: Prompt text
1214
+ multi: Allow multiple selections
1215
+ height: fzf height (e.g., "~50%", "20")
1216
+ ansi: Enable ANSI color codes
1217
+ no_sort: Disable sorting (preserve order)
1218
+ """
1219
+ self.header = header
1220
+ self.prompt = prompt
1221
+ self.multi = multi
1222
+ self.height = height
1223
+ self.ansi = ansi
1224
+ self.no_sort = no_sort
1225
+ self.options: List[Tuple[str, str]] = [] # (value, display_line)
1226
+ self.binds: List[Tuple[str, str]] = [] # (key, action)
1227
+ self.extra_args: List[str] = []
1228
+
1229
+ def add_option(self, value: str, display: str = "", description: str = "") -> "FzfMenu":
1230
+ """
1231
+ Add menu option.
1232
+
1233
+ Args:
1234
+ value: Value returned when selected (first field before tab)
1235
+ display: Display text (if empty, uses value)
1236
+ description: Optional description appended after display
1237
+
1238
+ Returns:
1239
+ self for chaining
1240
+ """
1241
+ if display:
1242
+ if description:
1243
+ line = f"{value}\t{display}\t{description}"
1244
+ else:
1245
+ line = f"{value}\t{display}"
1246
+ else:
1247
+ line = value
1248
+ self.options.append((value, line))
1249
+ return self
1250
+
1251
+ def add_line(self, line: str) -> "FzfMenu":
1252
+ """Add raw line to menu (for separators, headers, etc.)."""
1253
+ self.options.append(("", line))
1254
+ return self
1255
+
1256
+ def add_bind(self, key: str, action: str) -> "FzfMenu":
1257
+ """Add key binding."""
1258
+ self.binds.append((key, action))
1259
+ return self
1260
+
1261
+ def add_arg(self, *args: str) -> "FzfMenu":
1262
+ """Add extra fzf arguments."""
1263
+ self.extra_args.extend(args)
1264
+ return self
1265
+
1266
+ def show(self) -> Optional[Union[str, List[str]]]:
1267
+ """
1268
+ Show the menu and return selection.
1269
+
1270
+ Returns:
1271
+ - Single string if multi=False
1272
+ - List of strings if multi=True
1273
+ - None if cancelled or fzf not available
1274
+ """
1275
+ if not fzf_available():
1276
+ return None
1277
+
1278
+ cmd = ["fzf"]
1279
+
1280
+ if self.multi:
1281
+ cmd.append("--multi")
1282
+ else:
1283
+ cmd.append("--no-multi")
1284
+
1285
+ if self.header:
1286
+ cmd.extend(["--header", self.header])
1287
+
1288
+ if self.prompt:
1289
+ cmd.extend(["--prompt", self.prompt])
1290
+
1291
+ if self.height:
1292
+ cmd.extend(["--height", self.height])
1293
+
1294
+ if self.ansi:
1295
+ cmd.append("--ansi")
1296
+
1297
+ if self.no_sort:
1298
+ cmd.append("--no-sort")
1299
+
1300
+ # Use first field as selection value
1301
+ cmd.extend(["--with-nth", "2.."])
1302
+ cmd.extend(["--delimiter", "\t"])
1303
+
1304
+ for key, action in self.binds:
1305
+ cmd.extend(["--bind", f"{key}:{action}"])
1306
+
1307
+ cmd.extend(self.extra_args)
1308
+
1309
+ # Add theme colors
1310
+ cmd.extend(get_fzf_color_args())
1311
+
1312
+ menu_input = "\n".join(line for _, line in self.options)
1313
+
1314
+ try:
1315
+ result = subprocess.run(
1316
+ cmd,
1317
+ input=menu_input,
1318
+ capture_output=True,
1319
+ text=True,
1320
+ )
1321
+ except FileNotFoundError:
1322
+ return None
1323
+
1324
+ if result.returncode != 0:
1325
+ return None
1326
+
1327
+ output = result.stdout.strip()
1328
+ if not output:
1329
+ return None
1330
+
1331
+ if self.multi:
1332
+ # Return list of first fields (values)
1333
+ selections = []
1334
+ for line in output.split("\n"):
1335
+ parts = line.split("\t")
1336
+ selections.append(parts[0] if parts else line)
1337
+ return selections
1338
+ else:
1339
+ # Return first field (value)
1340
+ parts = output.split("\t")
1341
+ return parts[0] if parts else output
1342
+
1343
+
1344
+ def fzf_available() -> bool:
1345
+ """Check if fzf is available."""
1346
+ return shutil.which("fzf") is not None
1347
+
1348
+
1349
+ def parse_help_options(script_path: str, cmd: str) -> List[Tuple[str, str]]:
1350
+ """
1351
+ Parse options from a command's --help output.
1352
+
1353
+ Args:
1354
+ script_path: Path to the script executable
1355
+ cmd: Command name (e.g., "status", "init clone")
1356
+
1357
+ Returns:
1358
+ List of (option_flag, description) tuples.
1359
+ e.g., [("--verbose", "Show verbose output"), ("-v", "Verbose")]
1360
+ """
1361
+ try:
1362
+ # Build command: script_path cmd --help
1363
+ cmd_parts = [script_path] + cmd.split() + ["--help"]
1364
+ result = subprocess.run(
1365
+ cmd_parts,
1366
+ capture_output=True,
1367
+ text=True,
1368
+ timeout=5,
1369
+ )
1370
+ help_text = result.stdout + result.stderr
1371
+ except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
1372
+ return []
1373
+
1374
+ options: List[Tuple[str, str]] = []
1375
+ in_options_section = False
1376
+ current_option = None
1377
+ current_desc_lines: List[str] = []
1378
+
1379
+ for line in help_text.split("\n"):
1380
+ # Detect start of options section
1381
+ if line.strip().lower() in ("options:", "optional arguments:"):
1382
+ in_options_section = True
1383
+ continue
1384
+
1385
+ # Detect end of options section (new section or end)
1386
+ if in_options_section and line and not line.startswith(" ") and not line.startswith("\t"):
1387
+ # Save any pending option
1388
+ if current_option and current_option not in ("-h", "--help", "-h, --help"):
1389
+ desc = " ".join(current_desc_lines).strip()
1390
+ # Remove default value info for cleaner display
1391
+ if "(default:" in desc:
1392
+ desc = desc[:desc.rfind("(default:")].strip()
1393
+ options.append((current_option, desc))
1394
+ in_options_section = False
1395
+ current_option = None
1396
+ current_desc_lines = []
1397
+ continue
1398
+
1399
+ if not in_options_section:
1400
+ continue
1401
+
1402
+ # Parse option lines
1403
+ stripped = line.strip()
1404
+ if not stripped:
1405
+ continue
1406
+
1407
+ # Check if this is a new option line (starts with -)
1408
+ if stripped.startswith("-"):
1409
+ # Save previous option if exists
1410
+ if current_option and current_option not in ("-h", "--help", "-h, --help"):
1411
+ desc = " ".join(current_desc_lines).strip()
1412
+ if "(default:" in desc:
1413
+ desc = desc[:desc.rfind("(default:")].strip()
1414
+ options.append((current_option, desc))
1415
+
1416
+ # Parse new option
1417
+ # Format: "-v, --verbose Description" or "--path PATH Description"
1418
+ parts = stripped.split()
1419
+ opt_parts = []
1420
+ desc_start = 0
1421
+ for i, part in enumerate(parts):
1422
+ if part.startswith("-"):
1423
+ opt_parts.append(part.rstrip(","))
1424
+ elif part.isupper() and i > 0:
1425
+ # This is likely an argument placeholder like PATH
1426
+ continue
1427
+ else:
1428
+ desc_start = i
1429
+ break
1430
+
1431
+ # Prefer long option if available
1432
+ current_option = opt_parts[-1] if opt_parts else None
1433
+ # For combined short/long like "-v, --verbose", show both
1434
+ if len(opt_parts) > 1:
1435
+ current_option = ", ".join(opt_parts)
1436
+
1437
+ current_desc_lines = [" ".join(parts[desc_start:])] if desc_start > 0 else []
1438
+ else:
1439
+ # Continuation of description
1440
+ current_desc_lines.append(stripped)
1441
+
1442
+ # Don't forget the last option
1443
+ if current_option and current_option not in ("-h", "--help", "-h, --help"):
1444
+ desc = " ".join(current_desc_lines).strip()
1445
+ if "(default:" in desc:
1446
+ desc = desc[:desc.rfind("(default:")].strip()
1447
+ options.append((current_option, desc))
1448
+
1449
+ return options
1450
+
1451
+
1452
+ def fzf_expandable_menu(
1453
+ commands: List[Tuple[str, str, List[Tuple[str, str]]]],
1454
+ header: str = "Enter=select | \\=expand/collapse | q=quit",
1455
+ prompt: str = "> ",
1456
+ height: str = "~80%",
1457
+ preview_cmd: Optional[str] = None,
1458
+ preview_window: str = "right,60%,wrap",
1459
+ options_provider: Optional[Callable[[str], List[Tuple[str, str]]]] = None,
1460
+ categories: Optional[Dict[str, str]] = None,
1461
+ sort_key: Optional[str] = None,
1462
+ initial_selection: Optional[str] = None,
1463
+ ) -> Optional[Union[str, Tuple[str, str]]]:
1464
+ """
1465
+ Show an fzf menu with expandable subcommands.
1466
+
1467
+ Args:
1468
+ commands: List of (cmd, description, [(subcmd, subdesc), ...]) tuples.
1469
+ Commands should be in display order (alphabetical).
1470
+ header: Header text shown in fzf
1471
+ prompt: Prompt text
1472
+ height: fzf height setting
1473
+ preview_cmd: Optional shell command for preview pane ({1} = selected value)
1474
+ preview_window: Preview window position/size if preview_cmd is set
1475
+ options_provider: Optional function that takes a command name and returns
1476
+ a list of (option, description) tuples for that command.
1477
+ Used for verbose mode (v key) to show command options.
1478
+ categories: Optional dict mapping command names to category header strings.
1479
+ If a command has a category, a separator header is shown before it.
1480
+ Example: {"explore": "Git/Repository", "config": "Configuration"}
1481
+ sort_key: Optional key binding that triggers sort mode cycling.
1482
+ When pressed, returns ("SORT", current_selection) tuple.
1483
+ initial_selection: Optional command name to position cursor on initially.
1484
+
1485
+ Returns:
1486
+ - Selected command string on Enter
1487
+ - ("SORT", current_selection) tuple when sort_key is pressed
1488
+ - None if cancelled
1489
+ """
1490
+ if not fzf_available():
1491
+ return None
1492
+
1493
+ expanded_cmds: Set[str] = set()
1494
+ expanded_opts: Set[str] = set() # Commands with options expanded
1495
+ options_cache: Dict[str, List[Tuple[str, str]]] = {} # Cache parsed options
1496
+ current_selection: Optional[str] = initial_selection
1497
+ preview_hidden: bool = False
1498
+
1499
+ while True:
1500
+ # Build menu lines
1501
+ menu_lines: List[str] = []
1502
+
1503
+ # Calculate max length including subcommands and options
1504
+ all_cmds = []
1505
+ for cmd, desc, subs in commands:
1506
+ all_cmds.append(cmd)
1507
+ if cmd in expanded_cmds or cmd in expanded_opts:
1508
+ for subcmd, _ in subs:
1509
+ all_cmds.append(subcmd)
1510
+ if cmd in expanded_opts and options_provider:
1511
+ if cmd not in options_cache:
1512
+ options_cache[cmd] = options_provider(cmd)
1513
+ for opt, _ in options_cache.get(cmd, []):
1514
+ all_cmds.append(opt)
1515
+ # Also include subcommand options in length calculation
1516
+ for subcmd, _ in subs:
1517
+ if subcmd not in options_cache:
1518
+ options_cache[subcmd] = options_provider(subcmd)
1519
+ for opt, _ in options_cache.get(subcmd, []):
1520
+ all_cmds.append(opt)
1521
+ max_cmd_len = max(len(c) for c in all_cmds) if all_cmds else 12
1522
+
1523
+ for cmd, desc, subs in commands:
1524
+ # Check if this command starts a new category
1525
+ if categories and cmd in categories:
1526
+ cat_label = categories[cmd]
1527
+ # Add category separator (non-selectable via SEPARATOR key)
1528
+ separator_line = f"SEPARATOR\t\033[1;34m── {cat_label} ──\033[0m"
1529
+ menu_lines.append(separator_line)
1530
+ has_subs = len(subs) > 0
1531
+ is_expanded = cmd in expanded_cmds
1532
+ has_opts_expanded = cmd in expanded_opts
1533
+
1534
+ # Prefix: + for expandable, - for expanded, space otherwise
1535
+ if has_subs:
1536
+ prefix = "- " if (is_expanded or has_opts_expanded) else "+ "
1537
+ else:
1538
+ prefix = " "
1539
+
1540
+ colored_cmd = f"\033[36m{cmd:<{max_cmd_len}}\033[0m"
1541
+ menu_lines.append(f"{cmd}\t{prefix}{colored_cmd} {desc}")
1542
+
1543
+ # Build tree structure for sub-items
1544
+ # Level 1: parent's options + subcommands (direct children)
1545
+ # Level 2: subcommand's options (children of subcommands)
1546
+
1547
+ level1_items: List[Tuple[str, str, List[Tuple[str, str]]]] = []
1548
+ # (key, display, children) where children are level2 items
1549
+
1550
+ # Add parent's options first (if v was pressed) - no children
1551
+ if has_opts_expanded and options_provider:
1552
+ if cmd not in options_cache:
1553
+ options_cache[cmd] = options_provider(cmd)
1554
+ for opt, optdesc in options_cache.get(cmd, []):
1555
+ colored_opt = f"\033[33m{opt:<{max_cmd_len}}\033[0m" # Yellow
1556
+ level1_items.append((opt, f"{colored_opt} {optdesc}", []))
1557
+
1558
+ # Add subcommands (if \ or v was pressed)
1559
+ if is_expanded or has_opts_expanded:
1560
+ for subcmd, subdesc in subs:
1561
+ colored_subcmd = f"\033[36m{subcmd:<{max_cmd_len}}\033[0m"
1562
+ children: List[Tuple[str, str]] = []
1563
+
1564
+ # If verbose mode, collect subcommand's options as children
1565
+ if has_opts_expanded and options_provider:
1566
+ if subcmd not in options_cache:
1567
+ options_cache[subcmd] = options_provider(subcmd)
1568
+ for opt, optdesc in options_cache.get(subcmd, []):
1569
+ colored_opt = f"\033[33m{opt:<{max_cmd_len}}\033[0m"
1570
+ children.append((opt, f"{colored_opt} {optdesc}"))
1571
+
1572
+ level1_items.append((subcmd, f"{colored_subcmd} {subdesc}", children))
1573
+
1574
+ # Render tree with proper connectors
1575
+ for i, (key, display, children) in enumerate(level1_items):
1576
+ is_last_l1 = (i == len(level1_items) - 1)
1577
+ tree_char = "└─" if is_last_l1 else "├─"
1578
+ menu_lines.append(f"{key}\t {tree_char} {display}")
1579
+
1580
+ # Render children (level 2)
1581
+ for j, (child_key, child_display) in enumerate(children):
1582
+ is_last_l2 = (j == len(children) - 1)
1583
+ # Continuation line: │ if parent wasn't last, space if it was
1584
+ continuation = " " if is_last_l1 else "│ "
1585
+ child_tree = "└─" if is_last_l2 else "├─"
1586
+ menu_lines.append(f"{child_key}\t {continuation} {child_tree} {child_display}")
1587
+
1588
+ menu_input = "\n".join(menu_lines)
1589
+
1590
+ # Build fzf command
1591
+ fzf_args = [
1592
+ "fzf",
1593
+ "--no-multi",
1594
+ "--no-sort",
1595
+ "--no-info",
1596
+ "--ansi",
1597
+ "--sync", # Ensure input is fully loaded before start event fires
1598
+ "--layout=reverse-list",
1599
+ "--height", height,
1600
+ "--header", header,
1601
+ "--prompt", prompt,
1602
+ "--with-nth", "2..",
1603
+ "--delimiter", "\t",
1604
+ "--bind", "q:abort",
1605
+ ]
1606
+
1607
+ # Apply theme colors
1608
+ theme_colors = get_fzf_theme()
1609
+ if theme_colors:
1610
+ fzf_args.extend(["--color", theme_colors])
1611
+
1612
+ # Build expected keys list
1613
+ expect_keys = ["\\"] # backslash for subcommand expand/collapse
1614
+ if options_provider:
1615
+ expect_keys.append("v") # v for options expand/collapse
1616
+ if preview_cmd:
1617
+ expect_keys.append("?") # ? for preview toggle
1618
+ if sort_key:
1619
+ expect_keys.append(sort_key) # for sort mode cycling
1620
+
1621
+ # Add preview if specified
1622
+ if preview_cmd:
1623
+ window = f"{preview_window},hidden" if preview_hidden else preview_window
1624
+ fzf_args.extend([
1625
+ "--preview", preview_cmd,
1626
+ "--preview-window", window,
1627
+ "--bind", "ctrl-d:preview-half-page-down",
1628
+ "--bind", "ctrl-u:preview-half-page-up",
1629
+ "--bind", "page-down:preview-page-down",
1630
+ "--bind", "page-up:preview-page-up",
1631
+ ])
1632
+ fzf_args.extend(get_fzf_preview_resize_bindings())
1633
+
1634
+ fzf_args.extend(["--expect", ",".join(expect_keys)])
1635
+
1636
+ # Position cursor on previously selected item after expand/collapse
1637
+ if current_selection:
1638
+ for i, line in enumerate(menu_lines):
1639
+ if line.startswith(current_selection + '\t'):
1640
+ fzf_args.extend(["--bind", f"start:pos({i + 1})"])
1641
+ break
1642
+
1643
+ try:
1644
+ result = subprocess.run(
1645
+ fzf_args,
1646
+ input=menu_input,
1647
+ stdout=subprocess.PIPE,
1648
+ text=True,
1649
+ )
1650
+ except FileNotFoundError:
1651
+ return None
1652
+
1653
+ if result.returncode != 0 or not result.stdout.strip():
1654
+ return None
1655
+
1656
+ # Don't strip() before split - first line is empty when Enter pressed
1657
+ lines = result.stdout.split("\n")
1658
+ key_pressed = lines[0] if lines else ""
1659
+ selection = lines[1].strip() if len(lines) > 1 else ""
1660
+
1661
+ if not selection:
1662
+ return None
1663
+
1664
+ selected_cmd = selection.split("\t")[0]
1665
+
1666
+ # Handle sort mode cycling first (works on any line including separators)
1667
+ if sort_key and key_pressed == sort_key:
1668
+ # Return special tuple to signal sort mode change request
1669
+ # Use a valid command for cursor positioning, not SEPARATOR
1670
+ cursor_cmd = selected_cmd if selected_cmd != "SEPARATOR" else None
1671
+ return ("SORT", cursor_cmd)
1672
+
1673
+ # Handle preview toggle with ? (works on any line)
1674
+ if preview_cmd and key_pressed == "?":
1675
+ preview_hidden = not preview_hidden
1676
+ if selected_cmd != "SEPARATOR":
1677
+ current_selection = selected_cmd
1678
+ continue
1679
+
1680
+ # Skip category separator lines - they're not selectable for other actions
1681
+ if selected_cmd == "SEPARATOR":
1682
+ continue
1683
+
1684
+ # Handle expand/collapse subcommands with backslash
1685
+ if key_pressed == "\\":
1686
+ for cmd, _, subs in commands:
1687
+ if cmd == selected_cmd and subs:
1688
+ if cmd in expanded_cmds:
1689
+ expanded_cmds.discard(cmd)
1690
+ else:
1691
+ expanded_cmds.add(cmd)
1692
+ current_selection = cmd
1693
+ break
1694
+ continue
1695
+
1696
+ # Handle expand/collapse options with v (verbose)
1697
+ if key_pressed == "v" and options_provider:
1698
+ # Only allow options on top-level commands
1699
+ for cmd, _, _ in commands:
1700
+ if cmd == selected_cmd:
1701
+ if cmd in expanded_opts:
1702
+ expanded_opts.discard(cmd)
1703
+ else:
1704
+ expanded_opts.add(cmd)
1705
+ current_selection = cmd
1706
+ break
1707
+ continue
1708
+
1709
+ # Regular Enter - return the selected command
1710
+ return selected_cmd
1711
+
1712
+
1713
+ # =============================================================================
1714
+ # State Management
1715
+ # =============================================================================
1716
+
1717
+ def load_defaults(defaults_file: str) -> dict:
1718
+ """Load defaults from JSON file."""
1719
+ if not os.path.exists(defaults_file):
1720
+ return {}
1721
+ try:
1722
+ with open(defaults_file, encoding="utf-8") as f:
1723
+ data = json.load(f)
1724
+ if isinstance(data, dict):
1725
+ # Convert repo defaults to strings, but preserve special keys
1726
+ result = {}
1727
+ for k, v in data.items():
1728
+ if k in ("__extra_repos__", "__hidden_repos__"):
1729
+ result[k] = v if isinstance(v, list) else []
1730
+ elif k == "__push_targets__":
1731
+ result[k] = v if isinstance(v, dict) else {}
1732
+ else:
1733
+ result[str(k)] = str(v)
1734
+ return result
1735
+ except Exception:
1736
+ pass
1737
+ return {}
1738
+
1739
+
1740
+ def save_defaults(defaults_file: str, defaults: dict) -> None:
1741
+ """Save defaults to JSON file."""
1742
+ with open(defaults_file, "w", encoding="utf-8") as f:
1743
+ json.dump(defaults, f, indent=2, sort_keys=True)
1744
+
1745
+
1746
+ def load_prep_state(path: str) -> Optional[Dict]:
1747
+ """Load prep state from JSON file. Returns None if not found."""
1748
+ if not os.path.exists(path):
1749
+ return None
1750
+ try:
1751
+ with open(path, encoding="utf-8") as f:
1752
+ data = json.load(f)
1753
+ if isinstance(data, dict) and "repos" in data:
1754
+ return data
1755
+ except Exception:
1756
+ pass
1757
+ return None
1758
+
1759
+
1760
+ def save_prep_state(path: str, repos_data: Dict) -> None:
1761
+ """Save prep state to JSON file."""
1762
+ state = {
1763
+ "timestamp": datetime.now().isoformat(),
1764
+ "repos": repos_data
1765
+ }
1766
+ with open(path, "w", encoding="utf-8") as f:
1767
+ json.dump(state, f, indent=2)
1768
+
1769
+
1770
+ def load_export_state(path: str) -> Dict[str, Dict[str, str]]:
1771
+ """Load export state from JSON file."""
1772
+ if not os.path.exists(path):
1773
+ return {}
1774
+ try:
1775
+ with open(path, encoding="utf-8") as f:
1776
+ data = json.load(f)
1777
+ if isinstance(data, dict):
1778
+ return data
1779
+ except Exception:
1780
+ pass
1781
+ return {}
1782
+
1783
+
1784
+ def save_export_state(path: str, state: Dict[str, Dict[str, str]]) -> None:
1785
+ """Save export state to JSON file."""
1786
+ with open(path, "w", encoding="utf-8") as f:
1787
+ json.dump(state, f, indent=2, sort_keys=True)
1788
+
1789
+
1790
+ # =============================================================================
1791
+ # Standalone helper functions (for backward compatibility)
1792
+ # =============================================================================
1793
+
1794
+ def git_toplevel(path: str) -> Optional[str]:
1795
+ """Get the git repository root for a path, resolved to canonical form."""
1796
+ return GitRepo(path).toplevel()
1797
+
1798
+
1799
+ def current_branch(repo: str) -> Optional[str]:
1800
+ """Get current branch name for repo."""
1801
+ return GitRepo(repo).current_branch()
1802
+
1803
+
1804
+ def current_head(repo: str) -> Optional[str]:
1805
+ """Get current HEAD commit SHA for repo."""
1806
+ return GitRepo(repo).current_head()
1807
+
1808
+
1809
+ def repo_is_clean(repo: str) -> bool:
1810
+ """Check if repo has no uncommitted changes to tracked files."""
1811
+ return GitRepo(repo).is_clean()