#!/usr/bin/env python3 import curses import os import subprocess import sys import argparse VERSION = "2025-06-07.2" def get_commits(filename): cmd = ['git', 'log', '--pretty=format:%h %ad %s', '--date=short', '--', filename] result = subprocess.run(cmd, capture_output=True, text=True) return result.stdout.splitlines() def get_file_at_commit(commit_hash, filename): cmd = ['git', 'show', f'{commit_hash}:{filename}'] result = subprocess.run(cmd, capture_output=True, text=True) return result.stdout.splitlines() def find_best_matching_line(reference_line, file_lines, max_lines=None): """Find the best matching line in file_lines that matches reference_line. Returns the line index or None if no good match is found.""" if not reference_line or not file_lines: return None # First try exact match for i, line in enumerate(file_lines): if line == reference_line: return i # If no exact match, try to find the most similar line # Only search through a reasonable number of lines for performance search_lines = file_lines[:max_lines] if max_lines else file_lines best_match = None best_score = 0 for i, line in enumerate(search_lines): # Simple similarity score: count of common characters score = sum(1 for a, b in zip(reference_line, line) if a == b) # Adjust score based on length difference length_diff = abs(len(reference_line) - len(line)) adjusted_score = score - (length_diff * 0.5) if adjusted_score > best_score: best_score = adjusted_score best_match = i # Only return a match if it's reasonably good # (at least 60% of the shorter string length) min_length = min(len(reference_line), 1) # Avoid division by zero if best_score > (min_length * 0.6): return best_match return None def get_diff_info(current_commit, prev_commit, filename): """Get diff information between two commits for a file""" if not prev_commit: # For the first commit, we can't compare with a previous one return [], [] # Use a more detailed diff format to better understand the changes cmd = ['git', 'diff', prev_commit, current_commit, '--', filename] result = subprocess.run(cmd, capture_output=True, text=True) # Parse the diff output to extract added and deleted lines lines = result.stdout.splitlines() # Store line changes with their positions added_lines = [] deleted_lines = [] # Track the current position in the new file current_line_num = 0 deletion_offset = 0 # Parse the diff output i = 0 while i < len(lines): line = lines[i] # Parse hunk headers to get line numbers if line.startswith('@@'): # Format is like @@ -a,b +c,d @@ parts = line.split() if len(parts) >= 3: # Extract the starting line number in the new file add_part = parts[2][1:] # Remove the + sign if ',' in add_part: current_line_num, _ = map(int, add_part.split(',')) else: current_line_num = int(add_part) # Reset deletion offset for this hunk deletion_offset = 0 # Process added lines elif line.startswith('+') and not line.startswith('+++'): added_lines.append((current_line_num, line[1:])) current_line_num += 1 # Process deleted lines elif line.startswith('-') and not line.startswith('---'): # Store deleted lines with the position where they would have been # in the new file (before the next line) deleted_lines.append((current_line_num, line[1:])) deletion_offset += 1 # Process context lines elif not line.startswith('+') and not line.startswith('-') and not line.startswith('@@'): current_line_num += 1 i += 1 return added_lines, deleted_lines def main(stdscr, filename, show_whole_diff=False, show_additions=False, show_deletions=False): curses.curs_set(0) curses.mousemask(curses.ALL_MOUSE_EVENTS) curses.mouseinterval(0) stdscr.keypad(True) # Initialize colors if terminal supports them if curses.has_colors(): curses.use_default_colors() # Use terminal's default colors curses.init_pair(1, 5, 7) # Focused status bar curses.init_pair(2, curses.COLOR_WHITE, 8) # Unfocused status bar curses.init_pair(3, curses.COLOR_GREEN, -1) # Added lines curses.init_pair(4, curses.COLOR_RED, -1) # Deleted lines # Initialize key variable key = 0 commits = get_commits(filename) selected_commit = 0 divider_col = 40 focus = "left" scroll_offset = 0 left_scroll_offset = 0 # Scroll position for left pane dragging_divider = False # Initialize file content commit_hash = commits[selected_commit].split()[0] file_lines = get_file_at_commit(commit_hash, filename) # Get previous commit hash for diff prev_commit_hash = None if selected_commit < len(commits) - 1: prev_commit_hash = commits[selected_commit + 1].split()[0] # Get diff information if needed added_lines = [] deleted_lines = [] if show_whole_diff or show_additions or show_deletions: added_lines, deleted_lines = get_diff_info(commit_hash, prev_commit_hash, filename) # Enable nodelay for smoother scrolling stdscr.nodelay(True) while True: stdscr.erase() # Use erase instead of clear for less flickering height, width = stdscr.getmaxyx() # Calculate current scroll position as percentage before changing commits scroll_percentage = 0 reference_line = None if len(file_lines) > 0: max_scroll = max(0, len(file_lines) - (height - 1)) if max_scroll > 0: scroll_percentage = scroll_offset / max_scroll # Store the content of the top visible line as reference if scroll_offset < len(file_lines): reference_line = file_lines[scroll_offset] # Only fetch file content when commit changes if (key in [curses.KEY_DOWN, curses.KEY_UP, ord('j'), ord('k'), curses.KEY_NPAGE, ord(' '), curses.KEY_PPAGE, 8, 127, curses.KEY_SR]) and focus == "left": commit_hash = commits[selected_commit].split()[0] file_lines = get_file_at_commit(commit_hash, filename) # Update diff information when commit changes prev_commit_hash = None if selected_commit < len(commits) - 1: prev_commit_hash = commits[selected_commit + 1].split()[0] if show_whole_diff or show_additions or show_deletions: added_lines, deleted_lines = get_diff_info(commit_hash, prev_commit_hash, filename) # Try to find the same line in the new file version if reference_line: # Limit search to first 1000 lines for performance matching_line_idx = find_best_matching_line(reference_line, file_lines, 1000) if matching_line_idx is not None: scroll_offset = matching_line_idx else: # Fall back to percentage-based scrolling max_scroll = max(0, len(file_lines) - (height - 1)) if max_scroll > 0: scroll_offset = int(scroll_percentage * max_scroll) else: scroll_offset = 0 else: # Fall back to percentage-based scrolling max_scroll = max(0, len(file_lines) - (height - 1)) if max_scroll > 0: scroll_offset = int(scroll_percentage * max_scroll) else: scroll_offset = 0 # Recalculate max_scroll and ensure scroll_offset is within bounds max_scroll = max(0, len(file_lines) - (height - 1)) scroll_offset = min(scroll_offset, max_scroll) visible_lines = file_lines[scroll_offset:scroll_offset + height - 1] # Calculate visible commits for left pane left_max_scroll = max(0, len(commits) - (height - 1)) left_scroll_offset = min(left_scroll_offset, left_max_scroll) # Ensure selected commit is visible if selected_commit < left_scroll_offset: left_scroll_offset = selected_commit elif selected_commit >= left_scroll_offset + height - 1: left_scroll_offset = selected_commit - (height - 2) # Draw commit list (left pane) visible_commits = commits[left_scroll_offset:left_scroll_offset + height - 1] for i, line in enumerate(visible_commits): display_index = i + left_scroll_offset if display_index == selected_commit: stdscr.attron(curses.A_REVERSE) # Highlight selected commit stdscr.addnstr(i, 0, line, divider_col - 1) if display_index == selected_commit: stdscr.attroff(curses.A_REVERSE) # Vertical divider divider_char = "║" if dragging_divider else "│" for y in range(height): try: stdscr.addch(y, divider_col, divider_char) except curses.error: # Avoid errors when drawing at the last column pass # Draw file content (right pane) - more efficiently right_width = width - divider_col - 3 # First, collect all lines to display (regular, added, and deleted) display_lines = [] # Create a map of line numbers to deleted lines for faster lookup deleted_line_map = {} if show_whole_diff or show_deletions: for del_line_num, del_content in deleted_lines: if del_line_num not in deleted_line_map: deleted_line_map[del_line_num] = [] deleted_line_map[del_line_num].append(del_content) # Process each visible line for i, line in enumerate(visible_lines): line_num = i + scroll_offset + 1 # 1-based line number # First add any deleted lines that come before this line if (show_whole_diff or show_deletions) and line_num in deleted_line_map: for del_content in deleted_line_map[line_num]: display_lines.append({ 'type': 'deleted', 'content': del_content, 'line_num': line_num }) # Check if this is an added line is_added = False if show_whole_diff or show_additions: for added_line_num, _ in added_lines: if added_line_num == line_num: is_added = True break # Add the regular line to our display list display_lines.append({ 'type': 'added' if is_added else 'regular', 'content': line, 'line_num': line_num }) # Now display all lines display_row = 0 for line_info in display_lines: # Stop if we've reached the bottom of the screen if display_row >= height - 1: break line_type = line_info['type'] content = line_info['content'] if line_type == 'added': # Green with + prefix for added lines stdscr.addstr(display_row, divider_col + 2, "+ ", curses.color_pair(3)) stdscr.addnstr(display_row, divider_col + 4, content, right_width - 2, curses.color_pair(3)) elif line_type == 'deleted': # Red with - prefix for deleted lines stdscr.addstr(display_row, divider_col + 2, "- ", curses.color_pair(4)) stdscr.addnstr(display_row, divider_col + 4, content, right_width - 2, curses.color_pair(4)) else: # Regular line - add padding to align with +/- lines stdscr.addstr(display_row, divider_col + 2, " ") # Two spaces for alignment stdscr.addnstr(display_row, divider_col + 4, content, right_width - 2) display_row += 1 # Status bars for both panes visible_height = height - 1 # Reserve 1 line for the status bar # Right pane status last_visible_line = scroll_offset + visible_height if len(file_lines) > 0: right_percent = int((last_visible_line / len(file_lines)) * 100) right_percent = 100 if last_visible_line >= len(file_lines) else right_percent else: right_percent = 0 right_status = f"{right_percent}% " # Left pane status last_visible_commit = left_scroll_offset + visible_height if len(commits) > 0: left_percent = int((last_visible_commit / len(commits)) * 100) left_percent = 100 if last_visible_commit >= len(commits) else left_percent else: left_percent = 0 left_status = f"{left_percent}%" # Draw status bars - full width with different colors based on focus left_attr = curses.color_pair(1) if focus == "left" else curses.color_pair(2) right_attr = curses.color_pair(1) if focus == "right" else curses.color_pair(2) # Fill the entire bottom row for each pane for x in range(divider_col): stdscr.addch(height - 1, x, ' ', left_attr) for x in range(divider_col + 1, width - 1): # Avoid the last column stdscr.addch(height - 1, x, ' ', right_attr) # Add the percentage text stdscr.addstr(height - 1, 1, left_status, left_attr) stdscr.addstr(height - 1, width - len(right_status) - 1, right_status, right_attr) # Add divider character at the bottom row stdscr.addch(height - 1, divider_col, divider_char) # Get input with a small timeout for smoother scrolling stdscr.timeout(50) # 50ms timeout key = stdscr.getch() # If no key was pressed, continue the loop if key == -1: continue # Mouse interaction if key == curses.KEY_MOUSE: try: _, mx, my, _, bstate = curses.getmouse() # Handle divider dragging if bstate & curses.BUTTON1_PRESSED: # Start dragging when clicked near divider if abs(mx - divider_col) <= 1: # Allow clicking within 1 column of divider dragging_divider = True # If already dragging, update divider position if dragging_divider: # Update divider position while dragging min_col = 10 max_col = width - 20 # leave space for right pane divider_col = max(min_col, min(mx, max_col)) # Handle mouse release if bstate & curses.BUTTON1_RELEASED: if dragging_divider: dragging_divider = False else: # Change focus on mouse click (when not dragging) focus = "left" if mx < divider_col else "right" except curses.error: pass # Exit on 'q' or ESC elif key in [ord('q'), 27]: break # Left pane movement elif focus == "left": if key in [curses.KEY_DOWN, ord('j')]: if selected_commit < len(commits) - 1: selected_commit += 1 elif key in [curses.KEY_UP, ord('k')]: if selected_commit > 0: selected_commit -= 1 elif key in [curses.KEY_NPAGE, ord(' ')]: # Page down in left pane selected_commit = min(selected_commit + height - 1, len(commits) - 1) elif key in [curses.KEY_PPAGE, 8, 127, curses.KEY_SR]: # Page up in left pane selected_commit = max(0, selected_commit - (height - 1)) # Right pane scrolling elif focus == "right": if key in [curses.KEY_DOWN, ord('j')]: if scroll_offset < max_scroll: scroll_offset += 1 elif key in [curses.KEY_UP, ord('k')]: if scroll_offset > 0: scroll_offset -= 1 elif key in [curses.KEY_NPAGE, ord(' ')]: scroll_offset = min(scroll_offset + height - 1, max_scroll) elif key in [curses.KEY_PPAGE, 8, 127, curses.KEY_SR]: # Page Up, Backspace, Delete, or Shift+Up scroll_offset = max(0, scroll_offset - (height - 1)) elif key == curses.KEY_BACKSPACE: # Another way to detect Shift+Space in some terminals scroll_offset = max(0, scroll_offset - (height - 1)) # Pane switching if key in [curses.KEY_LEFT, ord('h')]: focus = "left" elif key in [curses.KEY_RIGHT, ord('l')]: focus = "right" stdscr.refresh() def show_help(): """Display help information""" parser.print_help() if __name__ == "__main__": parser = argparse.ArgumentParser(description="A \"Git Time Machine\" for viewing file history") parser.add_argument("-d", "--diff", action="store_true", help="Highlight newly added and deleted lines") parser.add_argument("--diff-additions", action="store_true", help="Highlight newly added lines in green") parser.add_argument("--diff-deletions", action="store_true", help="Show deleted lines in red") parser.add_argument("-v", "--version", action="store_true", help="Show version number") parser.add_argument("filename", nargs="?", help="File to view history for") args = parser.parse_args() if args.version: print(f"gtm version {VERSION}") sys.exit(0) elif not args.filename: parser.print_help() sys.exit(1) filename = args.filename # Check if the file exists if not os.path.isfile(filename): print(f"Error: File '{filename}' does not exist") sys.exit(1) curses.wrapper(main, filename, args.diff, args.diff_additions, args.diff_deletions)