Fortran · 97924 bytes Raw Blame History
1 module git_module
2 use iso_fortran_env, only: error_unit
3 use types_module
4 use cache_module
5 use terminal_module
6 implicit none
7
8 contains
9
10 ! ========== Performance Optimization: Binary Search Functions ==========
11
12 recursive subroutine quicksort_files(arr, low, high)
13 type(file_entry), intent(inout) :: arr(:)
14 integer, intent(in) :: low, high
15 integer :: pivot_idx
16
17 if (low < high) then
18 call partition_files(arr, low, high, pivot_idx)
19 call quicksort_files(arr, low, pivot_idx - 1)
20 call quicksort_files(arr, pivot_idx + 1, high)
21 end if
22 end subroutine quicksort_files
23
24 subroutine partition_files(arr, low, high, pivot_idx)
25 type(file_entry), intent(inout) :: arr(:)
26 integer, intent(in) :: low, high
27 integer, intent(out) :: pivot_idx
28 character(len=512) :: pivot_path
29 type(file_entry) :: temp
30 integer :: i, j
31
32 pivot_path = trim(arr(high)%path)
33 i = low - 1
34
35 do j = low, high - 1
36 if (trim(arr(j)%path) <= pivot_path) then
37 i = i + 1
38 ! Swap arr(i) and arr(j)
39 temp = arr(i)
40 arr(i) = arr(j)
41 arr(j) = temp
42 end if
43 end do
44
45 ! Swap arr(i+1) and arr(high)
46 temp = arr(i + 1)
47 arr(i + 1) = arr(high)
48 arr(high) = temp
49
50 pivot_idx = i + 1
51 end subroutine partition_files
52
53 function binary_search_file(arr, n, target_path) result(index)
54 type(file_entry), intent(in) :: arr(:)
55 integer, intent(in) :: n
56 character(len=*), intent(in) :: target_path
57 integer :: index
58 integer :: low, high, mid
59 character(len=512) :: target_trimmed, mid_path
60
61 index = -1 ! Not found
62 if (n == 0) return
63
64 target_trimmed = trim(target_path)
65 low = 1
66 high = n
67
68 do while (low <= high)
69 mid = low + (high - low) / 2
70 mid_path = trim(arr(mid)%path)
71
72 if (mid_path == target_trimmed) then
73 index = mid
74 return
75 else if (mid_path < target_trimmed) then
76 low = mid + 1
77 else
78 high = mid - 1
79 end if
80 end do
81 end function binary_search_file
82
83 ! ========== End Binary Search Functions ==========
84
85 ! ========== Gitignore Detection ==========
86
87 subroutine mark_gitignored_files(files, n_files)
88 type(file_entry), intent(inout) :: files(:)
89 integer, intent(in) :: n_files
90 integer :: iostat, unit_num_in, unit_num_out, status_code, i, idx
91 character(len=512), allocatable :: ignored_paths(:)
92 integer :: n_ignored, max_ignored
93 character(len=1024) :: line
94
95 if (n_files == 0) return
96
97 ! Write all file paths to a temp file for batch checking
98 open(newunit=unit_num_in, file='/tmp/fuss_check_ignore_in.txt', status='replace', action='write', iostat=iostat)
99 if (iostat /= 0) return
100
101 do i = 1, n_files
102 write(unit_num_in, '(A)') trim(files(i)%path)
103 end do
104 close(unit_num_in)
105
106 ! Use git check-ignore --stdin to check all files at once
107 call execute_command_line('git check-ignore --stdin < /tmp/fuss_check_ignore_in.txt > /tmp/fuss_check_ignore_out.txt 2>/dev/null', &
108 exitstat=status_code)
109
110 ! Read the output (files that ARE gitignored)
111 open(newunit=unit_num_out, file='/tmp/fuss_check_ignore_out.txt', status='old', action='read', iostat=iostat)
112 if (iostat /= 0) then
113 ! Clean up and return - no files are gitignored
114 call execute_command_line('rm -f /tmp/fuss_check_ignore_in.txt /tmp/fuss_check_ignore_out.txt', exitstat=status_code)
115 return
116 end if
117
118 ! Build sorted array of gitignored paths for binary search
119 max_ignored = 100
120 allocate(ignored_paths(max_ignored))
121 n_ignored = 0
122
123 do
124 read(unit_num_out, '(A)', iostat=iostat) line
125 if (iostat /= 0) exit
126
127 if (len_trim(line) > 0) then
128 n_ignored = n_ignored + 1
129 if (n_ignored > max_ignored) then
130 call resize_string_array(ignored_paths, max_ignored * 2)
131 max_ignored = max_ignored * 2
132 end if
133 ignored_paths(n_ignored) = trim(line)
134 end if
135 end do
136
137 close(unit_num_out)
138
139 if (n_ignored > 0) then
140 ! Sort for binary search
141 call quicksort_strings(ignored_paths, 1, n_ignored)
142
143 ! Mark gitignored files using binary search
144 do i = 1, n_files
145 idx = binary_search_string(ignored_paths, n_ignored, files(i)%path)
146 files(i)%is_gitignored = (idx > 0)
147 end do
148
149 deallocate(ignored_paths)
150 end if
151
152 ! Clean up temp files
153 call execute_command_line('rm -f /tmp/fuss_check_ignore_in.txt /tmp/fuss_check_ignore_out.txt', exitstat=status_code)
154 end subroutine mark_gitignored_files
155
156 ! ========== End Gitignore Detection ==========
157
158 ! Helper function to unquote filenames from git status --porcelain
159 ! Git quotes filenames that contain special characters (spaces, quotes, etc.)
160 function unquote_filename(quoted_path) result(unquoted_path)
161 character(len=*), intent(in) :: quoted_path
162 character(len=512) :: unquoted_path
163 integer :: i, j, path_len
164 character(len=512) :: temp_path
165
166 temp_path = trim(adjustl(quoted_path))
167 path_len = len_trim(temp_path)
168
169 ! Check if filename is quoted (starts and ends with double quote)
170 if (path_len >= 2 .and. temp_path(1:1) == '"' .and. temp_path(path_len:path_len) == '"') then
171 ! Remove surrounding quotes and handle escape sequences
172 unquoted_path = ''
173 j = 1
174 i = 2 ! Start after opening quote
175 do while (i < path_len)
176 if (temp_path(i:i) == '\' .and. i + 1 < path_len) then
177 ! Handle escape sequences
178 i = i + 1
179 select case (temp_path(i:i))
180 case ('n')
181 unquoted_path(j:j) = achar(10) ! newline
182 case ('t')
183 unquoted_path(j:j) = achar(9) ! tab
184 case ('\', '"')
185 unquoted_path(j:j) = temp_path(i:i) ! literal \ or "
186 case default
187 ! Unknown escape - keep as is
188 unquoted_path(j:j+1) = '\' // temp_path(i:i)
189 j = j + 1
190 end select
191 else
192 unquoted_path(j:j) = temp_path(i:i)
193 end if
194 i = i + 1
195 j = j + 1
196 end do
197 else
198 ! Not quoted - return as is
199 unquoted_path = temp_path
200 end if
201 end function unquote_filename
202
203 ! Internal implementation - called directly for actual git operations
204 subroutine get_dirty_files_impl(files, n_files)
205 type(file_entry), allocatable, intent(out) :: files(:)
206 integer, intent(out) :: n_files
207 integer :: iostat, unit_num, status_code
208 character(len=1024) :: line
209 character(len=512) :: file_path
210 character(len=2) :: git_status
211 integer :: max_files
212 type(file_entry), allocatable :: temp_files(:)
213
214 max_files = 1000
215 allocate(temp_files(max_files))
216 n_files = 0
217
218 ! Execute git status
219 call execute_command_line('git status --porcelain > /tmp/fuss_git_status.txt', exitstat=status_code)
220
221 if (status_code /= 0) then
222 write(error_unit, '(A)') 'Error: Not a git repository or git command failed'
223 allocate(files(0))
224 return
225 end if
226
227 ! Read git status output
228 open(newunit=unit_num, file='/tmp/fuss_git_status.txt', status='old', action='read', iostat=iostat)
229
230 if (iostat /= 0) then
231 allocate(files(0))
232 return
233 end if
234
235 do
236 read(unit_num, '(A)', iostat=iostat) line
237 if (iostat /= 0) exit
238
239 if (len_trim(line) > 3) then
240 ! Parse git status line (format: "XY filename")
241 git_status = line(1:2)
242 ! Unquote filename (git quotes filenames with spaces/special chars)
243 file_path = unquote_filename(line(4:))
244
245 ! Skip if path is empty
246 if (len_trim(file_path) == 0) cycle
247
248 ! Check if this is a directory entry (ending with /)
249 if (len_trim(file_path) > 0) then
250 if (file_path(len_trim(file_path):len_trim(file_path)) == '/') then
251 ! Directory entry - expand it to find all files inside
252 call expand_directory(file_path, git_status, temp_files, n_files, max_files)
253 cycle
254 end if
255 end if
256
257 n_files = n_files + 1
258 if (n_files > max_files) then
259 max_files = max_files * 2
260 call resize_array(temp_files, max_files)
261 end if
262
263 temp_files(n_files)%status = git_status
264 temp_files(n_files)%path = trim(file_path)
265 ! Column 1 = staged status, Column 2 = unstaged status
266 temp_files(n_files)%is_untracked = (git_status == '??')
267 temp_files(n_files)%is_staged = (git_status(1:1) /= ' ' .and. git_status(1:1) /= '?')
268 temp_files(n_files)%is_unstaged = (git_status(2:2) /= ' ' .and. .not. temp_files(n_files)%is_untracked)
269 temp_files(n_files)%has_incoming = .false.
270 temp_files(n_files)%is_gitignored = .false.
271 end if
272 end do
273
274 close(unit_num, status='delete')
275
276 ! Copy to output array
277 allocate(files(n_files))
278 if (n_files > 0) then
279 files(1:n_files) = temp_files(1:n_files)
280 ! Mark gitignored files
281 call mark_gitignored_files(files, n_files)
282 ! Sort for binary search optimization
283 call quicksort_files(files, 1, n_files)
284 end if
285 deallocate(temp_files)
286 end subroutine get_dirty_files_impl
287
288 ! Internal implementation - called directly for actual file operations
289 subroutine get_all_files_impl(files, n_files)
290 type(file_entry), allocatable, intent(out) :: files(:)
291 integer, intent(out) :: n_files
292 integer :: iostat, unit_num, status_code, i
293 character(len=1024) :: line
294 type(file_entry), allocatable :: dirty_files(:), temp_files(:)
295 integer :: n_dirty, max_files
296
297 ! First get dirty files (call impl directly to avoid double caching)
298 call get_dirty_files_impl(dirty_files, n_dirty)
299
300 ! Get all files using find
301 call execute_command_line('find . -type f ! -path "*/\.git/*" > /tmp/fuss_all_files.txt', exitstat=status_code)
302
303 if (status_code /= 0) then
304 allocate(files(n_dirty))
305 if (n_dirty > 0) files = dirty_files
306 n_files = n_dirty
307 if (allocated(dirty_files)) deallocate(dirty_files)
308 return
309 end if
310
311 open(newunit=unit_num, file='/tmp/fuss_all_files.txt', status='old', action='read', iostat=iostat)
312
313 if (iostat /= 0) then
314 allocate(files(n_dirty))
315 if (n_dirty > 0) files = dirty_files
316 n_files = n_dirty
317 if (allocated(dirty_files)) deallocate(dirty_files)
318 return
319 end if
320
321 max_files = 1000
322 allocate(temp_files(max_files))
323 n_files = 0
324
325 do
326 read(unit_num, '(A)', iostat=iostat) line
327 if (iostat /= 0) exit
328
329 if (len_trim(line) > 0) then
330 ! Remove leading "./"
331 if (len(line) >= 2) then
332 if (line(1:2) == './') line = line(3:)
333 end if
334
335 ! Skip if path is empty after trimming
336 if (len_trim(line) == 0) cycle
337
338 n_files = n_files + 1
339 if (n_files > max_files) then
340 max_files = max_files * 2
341 call resize_array(temp_files, max_files)
342 end if
343
344 ! Check if file is dirty and get status using binary search (O(log n) vs O(n))
345 temp_files(n_files)%path = trim(line)
346 temp_files(n_files)%status = ' ' ! Initialize as clean
347 temp_files(n_files)%is_staged = .false.
348 temp_files(n_files)%is_unstaged = .false.
349 temp_files(n_files)%is_untracked = .false.
350 temp_files(n_files)%has_incoming = .false.
351 temp_files(n_files)%is_gitignored = .false.
352
353 i = binary_search_file(dirty_files, n_dirty, line)
354 if (i > 0) then
355 ! Found in dirty files - copy status
356 temp_files(n_files)%status = dirty_files(i)%status
357 temp_files(n_files)%is_staged = dirty_files(i)%is_staged
358 temp_files(n_files)%is_unstaged = dirty_files(i)%is_unstaged
359 temp_files(n_files)%is_untracked = dirty_files(i)%is_untracked
360 temp_files(n_files)%has_incoming = dirty_files(i)%has_incoming
361 temp_files(n_files)%is_gitignored = dirty_files(i)%is_gitignored
362 end if
363 end if
364 end do
365
366 close(unit_num, status='delete')
367
368 allocate(files(n_files))
369 if (n_files > 0) then
370 files(1:n_files) = temp_files(1:n_files)
371 ! Mark gitignored files (this will update the is_gitignored field)
372 call mark_gitignored_files(files, n_files)
373 end if
374 deallocate(temp_files)
375 if (allocated(dirty_files)) deallocate(dirty_files)
376 end subroutine get_all_files_impl
377
378 ! ========== Cached Wrappers for File Retrieval ==========
379
380 ! Get dirty files with caching support
381 subroutine get_dirty_files(files, n_files, force_refresh)
382 type(file_entry), allocatable, intent(out) :: files(:)
383 integer, intent(out) :: n_files
384 logical, intent(in), optional :: force_refresh
385 logical :: do_refresh
386
387 do_refresh = .false.
388 if (present(force_refresh)) do_refresh = force_refresh
389
390 ! Force refresh if requested
391 if (do_refresh) then
392 call invalidate_cache(dirty_cache)
393 end if
394
395 ! Check cache validity
396 if (cache_valid(dirty_cache)) then
397 ! Use cached results
398 call get_cached_files(dirty_cache, files, n_files)
399 else
400 ! Call actual implementation
401 call get_dirty_files_impl(files, n_files)
402 ! Update cache with fresh data
403 call update_cache(dirty_cache, files, n_files)
404 end if
405 end subroutine get_dirty_files
406
407 ! Get all files with caching support
408 subroutine get_all_files(files, n_files, force_refresh)
409 type(file_entry), allocatable, intent(out) :: files(:)
410 integer, intent(out) :: n_files
411 logical, intent(in), optional :: force_refresh
412 logical :: do_refresh
413
414 do_refresh = .false.
415 if (present(force_refresh)) do_refresh = force_refresh
416
417 ! Force refresh if requested
418 if (do_refresh) then
419 call invalidate_cache(all_cache)
420 end if
421
422 ! Check cache validity
423 if (cache_valid(all_cache)) then
424 ! Use cached results
425 call get_cached_files(all_cache, files, n_files)
426 else
427 ! Call actual implementation
428 call get_all_files_impl(files, n_files)
429 ! Update cache with fresh data
430 call update_cache(all_cache, files, n_files)
431 end if
432 end subroutine get_all_files
433
434 ! ========== End Cached Wrappers ==========
435
436 subroutine resize_array(array, new_size)
437 type(file_entry), allocatable, intent(inout) :: array(:)
438 integer, intent(in) :: new_size
439 type(file_entry), allocatable :: temp(:)
440 integer :: old_size
441
442 old_size = size(array)
443 allocate(temp(old_size))
444 temp = array
445 deallocate(array)
446 allocate(array(new_size))
447 array(1:old_size) = temp
448 deallocate(temp)
449 end subroutine resize_array
450
451 subroutine expand_directory(dir_path, git_status, files, n_files, max_files)
452 character(len=*), intent(in) :: dir_path, git_status
453 type(file_entry), allocatable, intent(inout) :: files(:)
454 integer, intent(inout) :: n_files, max_files
455 integer :: iostat, unit_num, status_code
456 character(len=1024) :: line, command
457 character(len=512) :: dir_no_slash
458
459 ! Remove trailing slash
460 dir_no_slash = dir_path(1:len_trim(dir_path)-1)
461
462 ! Use find to list all files in this directory
463 write(command, '(A,A,A)') 'find "', trim(dir_no_slash), '" -type f > /tmp/fuss_expand_dir.txt'
464 call execute_command_line(trim(command), exitstat=status_code)
465
466 if (status_code /= 0) return
467
468 open(newunit=unit_num, file='/tmp/fuss_expand_dir.txt', status='old', action='read', iostat=iostat)
469 if (iostat /= 0) return
470
471 do
472 read(unit_num, '(A)', iostat=iostat) line
473 if (iostat /= 0) exit
474
475 if (len_trim(line) > 0) then
476 ! Remove leading "./" if present
477 if (len(line) >= 2) then
478 if (line(1:2) == './') line = line(3:)
479 end if
480
481 if (len_trim(line) == 0) cycle
482
483 n_files = n_files + 1
484 if (n_files > max_files) then
485 max_files = max_files * 2
486 call resize_array(files, max_files)
487 end if
488
489 files(n_files)%status = git_status
490 files(n_files)%path = trim(line)
491 files(n_files)%is_untracked = (git_status == '??')
492 files(n_files)%is_staged = (git_status(1:1) /= ' ' .and. git_status(1:1) /= '?')
493 files(n_files)%is_unstaged = (git_status(2:2) /= ' ' .and. .not. files(n_files)%is_untracked)
494 files(n_files)%has_incoming = .false.
495 files(n_files)%is_gitignored = .false.
496 end if
497 end do
498
499 close(unit_num, status='delete')
500 end subroutine expand_directory
501
502 subroutine git_add_file(filepath)
503 character(len=*), intent(in) :: filepath
504 character(len=1024) :: command
505 integer :: status
506
507 write(command, '(A,A,A)') 'git add "', trim(filepath), '"'
508 call execute_command_line(trim(command), exitstat=status)
509
510 ! Show feedback at bottom of screen
511 if (status == 0) then
512 print '(A)', 'Staged: ' // trim(filepath)
513 else
514 print '(A)', 'Failed to stage: ' // trim(filepath)
515 end if
516
517 ! Brief pause to show message
518 call execute_command_line('sleep 0.5', exitstat=status)
519 end subroutine git_add_file
520
521 subroutine git_unstage_file(filepath)
522 character(len=*), intent(in) :: filepath
523 character(len=1024) :: command
524 integer :: status
525
526 write(command, '(A,A,A)') 'git restore --staged "', trim(filepath), '"'
527 call execute_command_line(trim(command), exitstat=status)
528
529 ! Show feedback
530 if (status == 0) then
531 print '(A)', 'Unstaged: ' // trim(filepath)
532 else
533 print '(A)', 'Failed to unstage: ' // trim(filepath)
534 end if
535
536 ! Brief pause to show message
537 call execute_command_line('sleep 0.5', exitstat=status)
538 end subroutine git_unstage_file
539
540 subroutine git_stage_directory(dirpath)
541 character(len=*), intent(in) :: dirpath
542 character(len=1024) :: command
543 integer :: status
544
545 ! Stage all files in directory using git add
546 write(command, '(A,A,A)') 'git add "', trim(dirpath), '/"'
547 print '(A)', 'Staging directory: ' // trim(dirpath) // '/'
548 call execute_command_line(trim(command), exitstat=status)
549
550 ! Show feedback
551 if (status == 0) then
552 print '(A)', achar(27) // '[32m✓ Staged all files in: ' // trim(dirpath) // '/' // achar(27) // '[0m'
553 else
554 print '(A)', achar(27) // '[31m✗ Failed to stage directory: ' // trim(dirpath) // '/' // achar(27) // '[0m'
555 end if
556
557 ! Brief pause to show message
558 call execute_command_line('sleep 0.5', exitstat=status)
559 end subroutine git_stage_directory
560
561 subroutine git_stage_all()
562 integer :: status
563
564 ! Stage all changes (modified, deleted, and untracked files)
565 print '(A)', 'Staging all changes...'
566 call execute_command_line('git add --all', exitstat=status)
567
568 ! Show feedback
569 if (status == 0) then
570 print '(A)', achar(27) // '[32m✓ All changes staged!' // achar(27) // '[0m'
571 else
572 print '(A)', achar(27) // '[31m✗ Failed to stage all changes' // achar(27) // '[0m'
573 end if
574
575 ! Brief pause to show message
576 call execute_command_line('sleep 0.5', exitstat=status)
577 end subroutine git_stage_all
578
579 subroutine git_unstage_all()
580 integer :: status
581
582 ! Unstage all staged files
583 print '(A)', 'Unstaging all files...'
584 call execute_command_line('git restore --staged .', exitstat=status)
585
586 ! Show feedback
587 if (status == 0) then
588 print '(A)', achar(27) // '[32m✓ All files unstaged!' // achar(27) // '[0m'
589 else
590 print '(A)', achar(27) // '[31m✗ Failed to unstage files' // achar(27) // '[0m'
591 end if
592
593 ! Brief pause to show message
594 call execute_command_line('sleep 0.5', exitstat=status)
595 end subroutine git_unstage_all
596
597 subroutine git_discard_changes(filepath, is_staged, is_untracked, discarded)
598 use terminal_module, only: read_key
599 character(len=*), intent(in) :: filepath
600 logical, intent(in) :: is_staged, is_untracked
601 logical, intent(out) :: discarded
602 character(len=1024) :: command
603 character(len=1) :: key
604 integer :: status
605
606 discarded = .false.
607
608 ! Prompt for confirmation with different message based on file type
609 if (is_untracked) then
610 print '(A)', 'Discard (delete) untracked file ' // trim(filepath) // '?'
611 else if (is_staged) then
612 print '(A)', 'Discard changes and unstage ' // trim(filepath) // '?'
613 else
614 print '(A)', 'Discard changes to ' // trim(filepath) // '?'
615 end if
616 print '(A)', 'Press ''y'' to confirm, any other key to cancel.'
617
618 ! Read single key
619 call read_key(key)
620
621 ! Check if user confirmed
622 if (key == 'y' .or. key == 'Y') then
623 if (is_untracked) then
624 ! For untracked files, just delete them
625 write(command, '(A,A,A)') 'rm -f "', trim(filepath), '" 2>&1'
626 call execute_command_line(trim(command), exitstat=status)
627 else
628 ! For tracked files, restore from HEAD (works for both staged and unstaged)
629 ! git restore will unstage if needed and revert changes
630 write(command, '(A,A,A)') 'git restore --source=HEAD --staged --worktree "', trim(filepath), '" 2>&1'
631 call execute_command_line(trim(command), exitstat=status)
632 end if
633
634 if (status == 0) then
635 print '(A)', achar(27) // '[32m✓ Discarded changes: ' // trim(filepath) // achar(27) // '[0m'
636 discarded = .true.
637 else
638 print '(A)', achar(27) // '[31m✗ Failed to discard: ' // trim(filepath) // achar(27) // '[0m'
639 end if
640 print '(A)', ''
641 print '(A)', 'Press any key to continue...'
642 else
643 print '(A)', 'Discard cancelled.'
644 print '(A)', ''
645 print '(A)', 'Press any key to continue...'
646 end if
647 end subroutine git_discard_changes
648
649 subroutine git_delete_file(filepath, is_untracked, deleted)
650 use terminal_module, only: read_key
651 character(len=*), intent(in) :: filepath
652 logical, intent(in) :: is_untracked
653 logical, intent(out) :: deleted
654 character(len=1024) :: command
655 character(len=1) :: key
656 integer :: status
657
658 deleted = .false.
659
660 ! Prompt for confirmation
661 print '(A)', 'Delete ' // trim(filepath) // '? Press ''y'' to confirm, any other key to cancel.'
662
663 ! Read single key
664 call read_key(key)
665
666 ! Check if user confirmed
667 if (key == 'y' .or. key == 'Y') then
668 ! For untracked files, use rm; for tracked files, use git rm
669 if (is_untracked) then
670 write(command, '(A,A,A)') 'rm -f "', trim(filepath), '" 2>&1'
671 else
672 write(command, '(A,A,A)') 'git rm -f "', trim(filepath), '" 2>&1'
673 end if
674 call execute_command_line(trim(command), exitstat=status)
675
676 if (status == 0) then
677 print '(A)', achar(27) // '[32m✓ Deleted: ' // trim(filepath) // achar(27) // '[0m'
678 deleted = .true.
679 else
680 print '(A)', achar(27) // '[31m✗ Failed to delete: ' // trim(filepath) // achar(27) // '[0m'
681 end if
682 print '(A)', ''
683 print '(A)', 'Press any key to continue...'
684 else
685 print '(A)', 'Delete cancelled.'
686 print '(A)', ''
687 print '(A)', 'Press any key to continue...'
688 end if
689 end subroutine git_delete_file
690
691 subroutine git_commit_with_message(message, success)
692 character(len=*), intent(in) :: message
693 logical, intent(out) :: success
694 character(len=2048) :: command
695 integer :: status
696
697 ! Build git commit command with message
698 write(command, '(A,A,A)') 'git commit -m "', trim(message), '"'
699 call execute_command_line(trim(command), exitstat=status)
700
701 success = (status == 0)
702
703 ! Show feedback
704 if (success) then
705 print '(A)', achar(27) // '[32m✓ Committed successfully!' // achar(27) // '[0m'
706 else
707 print '(A)', achar(27) // '[31m✗ Commit failed (nothing staged?)' // achar(27) // '[0m'
708 end if
709
710 print '(A)', 'Press any key to continue...'
711 end subroutine git_commit_with_message
712
713 subroutine get_last_commit_message(message)
714 character(len=*), intent(out) :: message
715 integer :: status, unit_num, iostat
716 character(len=512) :: line
717
718 message = ''
719
720 ! Get the last commit message using git log
721 call execute_command_line('git log -1 --pretty=%B > ' // FUSS_TEMP // ' 2>/dev/null', exitstat=status)
722
723 if (status /= 0) return
724
725 ! Read the commit message
726 open(newunit=unit_num, file=FUSS_TEMP, status='old', action='read', iostat=iostat)
727 if (iostat /= 0) return
728
729 ! Read first line (single-line commit message)
730 read(unit_num, '(A)', iostat=iostat) line
731 if (iostat == 0) then
732 message = trim(line)
733 end if
734
735 close(unit_num, status='delete')
736 end subroutine get_last_commit_message
737
738 subroutine git_commit_amend(message, success)
739 character(len=*), intent(in) :: message
740 logical, intent(out) :: success
741 character(len=2048) :: command
742 integer :: status
743
744 ! Build git commit --amend command with message
745 write(command, '(A,A,A)') 'git commit --amend -m "', trim(message), '"'
746 call execute_command_line(trim(command), exitstat=status)
747
748 success = (status == 0)
749
750 ! Show feedback
751 if (success) then
752 print '(A)', achar(27) // '[32m✓ Commit amended successfully!' // achar(27) // '[0m'
753 else
754 print '(A)', achar(27) // '[31m✗ Amend failed' // achar(27) // '[0m'
755 end if
756
757 print '(A)', 'Press any key to continue...'
758 end subroutine git_commit_amend
759
760 subroutine get_git_status_output(status_lines, n_lines, max_lines)
761 character(len=512), allocatable, intent(out) :: status_lines(:)
762 integer, intent(out) :: n_lines
763 integer, intent(in) :: max_lines
764 integer :: iostat, unit_num, status_code
765 character(len=512) :: line
766 character(len=512), allocatable :: temp_lines(:)
767
768 allocate(temp_lines(max_lines))
769 n_lines = 0
770
771 ! Execute git status
772 call execute_command_line('git status > /tmp/fuss_full_status.txt', exitstat=status_code)
773
774 if (status_code /= 0) then
775 allocate(status_lines(1))
776 status_lines(1) = 'Error: Not a git repository'
777 n_lines = 1
778 return
779 end if
780
781 ! Read git status output
782 open(newunit=unit_num, file='/tmp/fuss_full_status.txt', status='old', action='read', iostat=iostat)
783
784 if (iostat /= 0) then
785 allocate(status_lines(1))
786 status_lines(1) = 'Error reading git status'
787 n_lines = 1
788 return
789 end if
790
791 do
792 read(unit_num, '(A)', iostat=iostat) line
793 if (iostat /= 0) exit
794
795 n_lines = n_lines + 1
796 if (n_lines > max_lines) exit
797 temp_lines(n_lines) = trim(line)
798 end do
799
800 close(unit_num, status='delete')
801
802 ! Copy to output
803 allocate(status_lines(n_lines))
804 if (n_lines > 0) status_lines(1:n_lines) = temp_lines(1:n_lines)
805 deallocate(temp_lines)
806 end subroutine get_git_status_output
807
808 subroutine git_push(success)
809 logical, intent(out) :: success
810 integer :: status, iostat, unit_num
811 character(len=512) :: current_branch
812 character(len=1024) :: command
813
814 success = .false.
815
816 ! Check if current branch has an upstream configured
817 call execute_command_line('git rev-parse --abbrev-ref @{upstream} > /dev/null 2>&1', exitstat=status)
818
819 if (status /= 0) then
820 ! No upstream - check if origin remote exists
821 call execute_command_line('git remote get-url origin > /dev/null 2>&1', exitstat=status)
822
823 if (status /= 0) then
824 ! No origin remote - offer to select upstream manually
825 print '(A)', ''
826 print '(A)', 'No upstream configured and no origin remote found.'
827 call prompt_upstream_selection(success)
828 return
829 end if
830
831 ! Origin exists - get current branch name and push to origin
832 call execute_command_line('git rev-parse --abbrev-ref HEAD > ' // FUSS_TEMP // ' 2>&1', &
833 exitstat=status)
834
835 if (status /= 0) then
836 print '(A)', achar(27) // '[31m✗ Could not determine current branch' // achar(27) // '[0m'
837 print '(A)', 'Press any key to continue...'
838 return
839 end if
840
841 ! Read current branch name
842 open(newunit=unit_num, file=FUSS_TEMP, status='old', action='read', iostat=iostat)
843 if (iostat /= 0) then
844 print '(A)', achar(27) // '[31m✗ Could not read branch name' // achar(27) // '[0m'
845 print '(A)', 'Press any key to continue...'
846 return
847 end if
848
849 read(unit_num, '(A)', iostat=iostat) current_branch
850 close(unit_num, status='delete')
851
852 if (iostat /= 0 .or. len_trim(current_branch) == 0) then
853 print '(A)', achar(27) // '[31m✗ Invalid branch name' // achar(27) // '[0m'
854 print '(A)', 'Press any key to continue...'
855 return
856 end if
857
858 ! Push with upstream configuration to origin
859 print '(A)', 'No upstream configured. Pushing to origin/' // trim(current_branch) // '...'
860 write(command, '(A,A,A)') 'git push -u origin "', trim(current_branch), '"'
861 call execute_command_line(trim(command), exitstat=status)
862
863 if (status == 0) then
864 print '(A)', achar(27) // '[32m✓ Pushed and set upstream to origin/' // trim(current_branch) // achar(27) // '[0m'
865 success = .true.
866 else
867 print '(A)', achar(27) // '[31m✗ Push failed - select upstream manually...' // achar(27) // '[0m'
868 print '(A)', ''
869 call prompt_upstream_selection(success)
870 end if
871 else
872 ! Upstream exists - do regular push
873 print '(A)', 'Pushing to upstream...'
874 call execute_command_line('git push', exitstat=status)
875
876 if (status == 0) then
877 print '(A)', achar(27) // '[32m✓ Pushed successfully!' // achar(27) // '[0m'
878 success = .true.
879 else
880 print '(A)', achar(27) // '[31m✗ Push failed (check remote/branch)' // achar(27) // '[0m'
881 end if
882 end if
883
884 print '(A)', 'Press any key to continue...'
885 end subroutine git_push
886
887 subroutine show_git_status_paged()
888 integer :: status
889
890 ! Disable cbreak mode temporarily (will be restored by caller)
891 call execute_command_line('stty sane < /dev/tty', exitstat=status)
892
893 ! Use less for scrollable git status display
894 ! -R preserves colors, -X prevents screen clear on exit
895 call execute_command_line('git status | less -RX', exitstat=status, wait=.true.)
896
897 ! Re-enable cbreak mode
898 call execute_command_line('stty cbreak -echo < /dev/tty', exitstat=status)
899 end subroutine show_git_status_paged
900
901 subroutine get_repo_info(repo_name, branch_name)
902 character(len=*), intent(out) :: repo_name, branch_name
903 integer :: iostat, unit_num, status_code
904
905 repo_name = ''
906 branch_name = ''
907
908 ! Get repo name (basename of repo root)
909 call execute_command_line('git rev-parse --show-toplevel 2>/dev/null | xargs basename > ' // FUSS_TEMP // '', &
910 exitstat=status_code)
911
912 if (status_code == 0) then
913 open(newunit=unit_num, file=FUSS_TEMP, status='old', action='read', iostat=iostat)
914 if (iostat == 0) then
915 read(unit_num, '(A)', iostat=iostat) repo_name
916 close(unit_num, status='delete')
917 end if
918 end if
919
920 ! Get current branch name
921 call execute_command_line('git rev-parse --abbrev-ref HEAD 2>/dev/null > ' // FUSS_TEMP // '', &
922 exitstat=status_code)
923
924 if (status_code == 0) then
925 open(newunit=unit_num, file=FUSS_TEMP, status='old', action='read', iostat=iostat)
926 if (iostat == 0) then
927 read(unit_num, '(A)', iostat=iostat) branch_name
928 close(unit_num, status='delete')
929 end if
930 end if
931 end subroutine get_repo_info
932
933 subroutine prompt_upstream_selection(success)
934 logical, intent(out) :: success
935 integer :: status_code
936 character(len=512) :: selected_branch
937
938 success = .false.
939
940 print '(A)', ''
941 print '(A)', 'No upstream branch configured for this branch.'
942 print '(A)', 'Select a remote branch to track:'
943 print '(A)', ''
944
945 ! Restore terminal for fzf
946 call execute_command_line('stty sane < /dev/tty', exitstat=status_code)
947
948 ! Use fzf to select remote branch
949 call execute_command_line('git branch -r | grep -v HEAD | sed "s/^ //" | ' // &
950 'fzf --height=10 --border=rounded --border-label=" ESC to cancel " ' // &
951 '--prompt="Select upstream: " > ' // FUSS_TEMP // '', &
952 exitstat=status_code)
953
954 if (status_code /= 0) then
955 print '(A)', 'No upstream selected.'
956 print '(A)', ''
957 print '(A)', 'Press any key to continue...'
958 ! Re-enable cbreak mode
959 call execute_command_line('stty cbreak -echo < /dev/tty', exitstat=status_code)
960 return
961 end if
962
963 ! Read selected branch
964 open(unit=99, file=FUSS_TEMP, status='old', action='read', iostat=status_code)
965 if (status_code == 0) then
966 read(99, '(A)', iostat=status_code) selected_branch
967 close(99, status='delete')
968
969 if (status_code == 0 .and. len_trim(selected_branch) > 0) then
970 ! Set upstream
971 call execute_command_line('git branch --set-upstream-to=' // trim(selected_branch), &
972 exitstat=status_code)
973
974 if (status_code == 0) then
975 print '(A)', achar(27) // '[32m✓ Upstream set to: ' // trim(selected_branch) // achar(27) // '[0m'
976 success = .true.
977 else
978 print '(A)', achar(27) // '[31m✗ Failed to set upstream' // achar(27) // '[0m'
979 end if
980 print '(A)', ''
981 print '(A)', 'Press any key to continue...'
982 end if
983 end if
984
985 ! Re-enable cbreak mode
986 call execute_command_line('stty cbreak -echo < /dev/tty', exitstat=status_code)
987 end subroutine prompt_upstream_selection
988
989 subroutine add_incoming_files(files, n_files)
990 ! Adds files with incoming changes that aren't already in the dirty files list
991 type(file_entry), allocatable, intent(inout) :: files(:)
992 integer, intent(inout) :: n_files
993 integer :: iostat, unit_num, status_code, i
994 character(len=1024) :: line
995 character(len=512) :: incoming_path
996 logical :: already_exists
997 type(file_entry), allocatable :: temp_files(:)
998 integer :: max_files, original_count
999
1000 ! Check if there's an upstream branch configured
1001 ! Don't prompt - this is called automatically during refresh
1002 call execute_command_line('git rev-parse --abbrev-ref @{upstream} > /dev/null 2>&1', exitstat=status_code)
1003 if (status_code /= 0) then
1004 ! No upstream configured - silently return
1005 return
1006 end if
1007
1008 ! Get list of files that differ between HEAD and upstream
1009 call execute_command_line('git diff --name-only HEAD...@{upstream} > /tmp/fuss_incoming.txt 2>/dev/null', &
1010 exitstat=status_code)
1011
1012 if (status_code /= 0) return
1013
1014 open(newunit=unit_num, file='/tmp/fuss_incoming.txt', status='old', action='read', iostat=iostat)
1015 if (iostat /= 0) return
1016
1017 ! Count current files and prepare to add more
1018 original_count = n_files
1019 max_files = n_files + 100 ! Reserve space for incoming files
1020 allocate(temp_files(max_files))
1021
1022 ! Copy existing files
1023 if (n_files > 0) temp_files(1:n_files) = files(1:n_files)
1024
1025 do
1026 read(unit_num, '(A)', iostat=iostat) line
1027 if (iostat /= 0) exit
1028
1029 if (len_trim(line) > 0) then
1030 ! Unquote filename (git quotes filenames with spaces/special chars)
1031 incoming_path = trim(unquote_filename(line))
1032
1033 ! Check if this file already exists in the list
1034 already_exists = .false.
1035 do i = 1, n_files
1036 if (trim(temp_files(i)%path) == trim(incoming_path)) then
1037 temp_files(i)%has_incoming = .true.
1038 already_exists = .true.
1039 exit
1040 end if
1041 end do
1042
1043 ! If not in list, add it as a clean file with incoming changes
1044 if (.not. already_exists) then
1045 n_files = n_files + 1
1046 if (n_files > max_files) then
1047 max_files = max_files * 2
1048 call resize_array(temp_files, max_files)
1049 end if
1050
1051 temp_files(n_files)%path = trim(incoming_path)
1052 temp_files(n_files)%status = ' ' ! Clean locally
1053 temp_files(n_files)%is_staged = .false.
1054 temp_files(n_files)%is_unstaged = .false.
1055 temp_files(n_files)%is_untracked = .false.
1056 temp_files(n_files)%has_incoming = .true.
1057 temp_files(n_files)%is_gitignored = .false.
1058 end if
1059 end if
1060 end do
1061
1062 close(unit_num, status='delete')
1063
1064 ! Copy back to files array if we added new files
1065 if (n_files > original_count) then
1066 deallocate(files)
1067 allocate(files(n_files))
1068 files(1:n_files) = temp_files(1:n_files)
1069 end if
1070
1071 deallocate(temp_files)
1072 end subroutine add_incoming_files
1073
1074 subroutine mark_incoming_changes(files, n_files)
1075 type(file_entry), intent(inout) :: files(:)
1076 integer, intent(in) :: n_files
1077 integer :: iostat, unit_num, status_code, i, idx
1078 character(len=1024) :: line
1079 character(len=512), allocatable :: incoming_paths(:)
1080 integer :: n_incoming, max_incoming
1081
1082 ! Check if there's an upstream branch configured
1083 ! Don't prompt - this is called automatically during refresh
1084 call execute_command_line('git rev-parse --abbrev-ref @{upstream} > /dev/null 2>&1', exitstat=status_code)
1085 if (status_code /= 0) then
1086 ! No upstream configured - silently return
1087 return
1088 end if
1089
1090 ! Get list of files that differ between HEAD and upstream
1091 call execute_command_line('git diff --name-only HEAD...@{upstream} > /tmp/fuss_incoming.txt 2>/dev/null', &
1092 exitstat=status_code)
1093
1094 if (status_code /= 0) then
1095 ! If diff fails, no incoming changes
1096 return
1097 end if
1098
1099 open(newunit=unit_num, file='/tmp/fuss_incoming.txt', status='old', action='read', iostat=iostat)
1100 if (iostat /= 0) return
1101
1102 ! Build sorted array of incoming file paths for binary search
1103 max_incoming = 100
1104 allocate(incoming_paths(max_incoming))
1105 n_incoming = 0
1106
1107 do
1108 read(unit_num, '(A)', iostat=iostat) line
1109 if (iostat /= 0) exit
1110
1111 if (len_trim(line) > 0) then
1112 n_incoming = n_incoming + 1
1113 if (n_incoming > max_incoming) then
1114 ! Resize array
1115 call resize_string_array(incoming_paths, max_incoming * 2)
1116 max_incoming = max_incoming * 2
1117 end if
1118 ! Unquote filename (git quotes filenames with spaces/special chars)
1119 incoming_paths(n_incoming) = trim(unquote_filename(line))
1120 end if
1121 end do
1122
1123 close(unit_num, status='delete')
1124
1125 if (n_incoming == 0) then
1126 deallocate(incoming_paths)
1127 return
1128 end if
1129
1130 ! Sort incoming paths for binary search
1131 call quicksort_strings(incoming_paths, 1, n_incoming)
1132
1133 ! Use binary search to mark files with incoming changes (O(n log m) vs O(n×m))
1134 do i = 1, n_files
1135 idx = binary_search_string(incoming_paths, n_incoming, files(i)%path)
1136 if (idx > 0) then
1137 files(i)%has_incoming = .true.
1138 end if
1139 end do
1140
1141 deallocate(incoming_paths)
1142 end subroutine mark_incoming_changes
1143
1144 ! Helper subroutines for string array sorting and searching
1145 subroutine resize_string_array(array, new_size)
1146 character(len=512), allocatable, intent(inout) :: array(:)
1147 integer, intent(in) :: new_size
1148 character(len=512), allocatable :: temp(:)
1149 integer :: old_size
1150
1151 old_size = size(array)
1152 allocate(temp(old_size))
1153 temp = array
1154 deallocate(array)
1155 allocate(array(new_size))
1156 array(1:old_size) = temp
1157 deallocate(temp)
1158 end subroutine resize_string_array
1159
1160 recursive subroutine quicksort_strings(arr, low, high)
1161 character(len=512), intent(inout) :: arr(:)
1162 integer, intent(in) :: low, high
1163 integer :: pivot_idx
1164
1165 if (low < high) then
1166 call partition_strings(arr, low, high, pivot_idx)
1167 call quicksort_strings(arr, low, pivot_idx - 1)
1168 call quicksort_strings(arr, pivot_idx + 1, high)
1169 end if
1170 end subroutine quicksort_strings
1171
1172 subroutine partition_strings(arr, low, high, pivot_idx)
1173 character(len=512), intent(inout) :: arr(:)
1174 integer, intent(in) :: low, high
1175 integer, intent(out) :: pivot_idx
1176 character(len=512) :: pivot, temp
1177 integer :: i, j
1178
1179 pivot = trim(arr(high))
1180 i = low - 1
1181
1182 do j = low, high - 1
1183 if (trim(arr(j)) <= pivot) then
1184 i = i + 1
1185 temp = arr(i)
1186 arr(i) = arr(j)
1187 arr(j) = temp
1188 end if
1189 end do
1190
1191 temp = arr(i + 1)
1192 arr(i + 1) = arr(high)
1193 arr(high) = temp
1194
1195 pivot_idx = i + 1
1196 end subroutine partition_strings
1197
1198 function binary_search_string(arr, n, target) result(index)
1199 character(len=512), intent(in) :: arr(:)
1200 integer, intent(in) :: n
1201 character(len=*), intent(in) :: target
1202 integer :: index
1203 integer :: low, high, mid
1204 character(len=512) :: target_trimmed, mid_val
1205
1206 index = -1
1207 if (n == 0) return
1208
1209 target_trimmed = trim(target)
1210 low = 1
1211 high = n
1212
1213 do while (low <= high)
1214 mid = low + (high - low) / 2
1215 mid_val = trim(arr(mid))
1216
1217 if (mid_val == target_trimmed) then
1218 index = mid
1219 return
1220 else if (mid_val < target_trimmed) then
1221 low = mid + 1
1222 else
1223 high = mid - 1
1224 end if
1225 end do
1226 end function binary_search_string
1227
1228 subroutine git_fetch()
1229 integer :: status
1230 logical :: upstream_set
1231
1232 ! Check if there's an upstream branch configured
1233 call execute_command_line('git rev-parse --abbrev-ref @{upstream} > /dev/null 2>&1', exitstat=status)
1234 if (status /= 0) then
1235 ! No upstream configured - prompt user to select one
1236 call prompt_upstream_selection(upstream_set)
1237 if (.not. upstream_set) return
1238 end if
1239
1240 ! Run git fetch
1241 print '(A)', 'Fetching from remote...'
1242 call execute_command_line('git fetch', exitstat=status)
1243
1244 if (status == 0) then
1245 print '(A)', achar(27) // '[32m✓ Fetch completed!' // achar(27) // '[0m'
1246 else
1247 print '(A)', achar(27) // '[31m✗ Fetch failed!' // achar(27) // '[0m'
1248 end if
1249
1250 ! Brief pause to show message
1251 call execute_command_line('sleep 1', exitstat=status)
1252 end subroutine git_fetch
1253
1254 subroutine git_pull()
1255 integer :: status
1256 logical :: upstream_set
1257
1258 ! Check if there's an upstream branch configured
1259 call execute_command_line('git rev-parse --abbrev-ref @{upstream} > /dev/null 2>&1', exitstat=status)
1260 if (status /= 0) then
1261 ! No upstream configured - prompt user to select one
1262 call prompt_upstream_selection(upstream_set)
1263 if (.not. upstream_set) return
1264 end if
1265
1266 ! Run git pull
1267 print '(A)', 'Pulling from remote...'
1268 call execute_command_line('git pull', exitstat=status)
1269
1270 if (status == 0) then
1271 print '(A)', achar(27) // '[32m✓ Pull completed!' // achar(27) // '[0m'
1272 else
1273 print '(A)', achar(27) // '[31m✗ Pull failed!' // achar(27) // '[0m'
1274 end if
1275
1276 ! Brief pause to show message
1277 call execute_command_line('sleep 1', exitstat=status)
1278 end subroutine git_pull
1279
1280 subroutine git_diff_file(filepath, has_incoming)
1281 character(len=*), intent(in) :: filepath
1282 logical, intent(in) :: has_incoming
1283 character(len=2048) :: command
1284 integer :: status
1285 logical :: upstream_set
1286 logical :: has_local_changes
1287
1288 ! Exit alternate screen and restore terminal for less
1289 call exit_alternate_screen()
1290 call execute_command_line('stty sane < /dev/tty', exitstat=status)
1291
1292 ! Check if file has local changes (unstaged or staged)
1293 call execute_command_line('git status --porcelain -- "' // trim(filepath) // '" | grep -q "^.M\|^M"', &
1294 exitstat=status)
1295 has_local_changes = (status == 0)
1296
1297 if (has_local_changes .and. has_incoming) then
1298 ! Show both local changes and incoming changes
1299 print '(A)', achar(27) // '[1;33mShowing LOCAL changes (working tree vs HEAD):' // achar(27) // '[0m'
1300 print '(A)', ''
1301 write(command, '(A,A,A)') '(git diff HEAD -- "', trim(filepath), '" && echo "" && echo "' // &
1302 achar(27) // '[1;33m=== INCOMING changes (upstream vs HEAD) ===' // achar(27) // &
1303 '[0m" && echo "" && git diff HEAD...@{upstream} -- "', trim(filepath), &
1304 '") | less -R'
1305 call execute_command_line(trim(command), exitstat=status)
1306 else if (has_local_changes) then
1307 ! Show local changes only (working tree vs HEAD)
1308 print '(A)', achar(27) // '[1;33mShowing LOCAL changes (working tree vs HEAD):' // achar(27) // '[0m'
1309 print '(A)', ''
1310 write(command, '(A,A,A)') 'git diff HEAD -- "', trim(filepath), '" | less -R'
1311 call execute_command_line(trim(command), exitstat=status)
1312 else if (has_incoming) then
1313 ! Show incoming changes only (upstream vs HEAD)
1314 ! Check if there's an upstream branch configured
1315 call execute_command_line('git rev-parse --abbrev-ref @{upstream} > /dev/null 2>&1', exitstat=status)
1316 if (status /= 0) then
1317 ! No upstream configured - prompt user to select one
1318 call prompt_upstream_selection(upstream_set)
1319 if (.not. upstream_set) then
1320 call enter_alternate_screen()
1321 call execute_command_line('stty cbreak -echo < /dev/tty', exitstat=status)
1322 return
1323 end if
1324 end if
1325
1326 print '(A)', achar(27) // '[1;33mShowing INCOMING changes (upstream vs HEAD):' // achar(27) // '[0m'
1327 print '(A)', ''
1328 write(command, '(A,A,A)') 'git diff HEAD...@{upstream} -- "', trim(filepath), '" | less -R'
1329 call execute_command_line(trim(command), exitstat=status)
1330 else
1331 ! No changes to show
1332 print '(A)', achar(27) // '[33mNo changes to show for: ' // trim(filepath) // achar(27) // '[0m'
1333 call execute_command_line('sleep 1', exitstat=status)
1334 end if
1335
1336 ! Re-enter alternate screen and re-enable cbreak mode
1337 call enter_alternate_screen()
1338 call execute_command_line('stty cbreak -echo < /dev/tty', exitstat=status)
1339 end subroutine git_diff_file
1340
1341 subroutine view_file(filepath)
1342 character(len=*), intent(in) :: filepath
1343 character(len=2048) :: command
1344 integer :: status
1345 logical :: bat_available, less_available
1346
1347 ! Exit alternate screen and restore terminal for pager
1348 call exit_alternate_screen()
1349 call execute_command_line('stty sane < /dev/tty', exitstat=status)
1350
1351 ! Check if bat is available
1352 call execute_command_line('command -v bat > /dev/null 2>&1', exitstat=status)
1353 bat_available = (status == 0)
1354
1355 ! Check if less is available
1356 call execute_command_line('command -v less > /dev/null 2>&1', exitstat=status)
1357 less_available = (status == 0)
1358
1359 ! Use bat with paging if available (with nice syntax highlighting)
1360 if (bat_available) then
1361 write(command, '(A,A,A)') 'bat --style=numbers,changes --paging=always "', trim(filepath), '"'
1362 call execute_command_line(trim(command), exitstat=status)
1363 else if (less_available) then
1364 ! Fallback to less
1365 write(command, '(A,A,A)') 'less "', trim(filepath), '"'
1366 call execute_command_line(trim(command), exitstat=status)
1367 else
1368 ! Final fallback to cat with line numbers
1369 write(command, '(A,A,A)') 'cat -n "', trim(filepath), '"'
1370 call execute_command_line(trim(command), exitstat=status)
1371 ! Pause so user can read
1372 print '(A)', ''
1373 print '(A)', 'Press any key to continue...'
1374 call execute_command_line('read -n 1 -s < /dev/tty', exitstat=status)
1375 end if
1376
1377 ! Re-enter alternate screen and re-enable cbreak mode
1378 call enter_alternate_screen()
1379 call execute_command_line('stty cbreak -echo < /dev/tty', exitstat=status)
1380 end subroutine view_file
1381
1382 subroutine git_cherry_pick(success)
1383 logical, intent(out) :: success
1384 integer :: status_code, status
1385 character(len=512) :: selected_branch, selected_commit
1386 character(len=2048) :: command
1387
1388 success = .false.
1389
1390 ! Restore terminal for fzf
1391 call execute_command_line('stty sane < /dev/tty', exitstat=status)
1392
1393 print '(A)', achar(27) // '[1mCherry-pick' // achar(27) // '[0m'
1394 print '(A)', ''
1395 print '(A)', 'Select source branch:'
1396 print '(A)', ''
1397
1398 ! Step 1: Select branch (exclude current branch)
1399 call execute_command_line('(git branch --all | grep -v HEAD | grep -v "^\*" | sed "s/^[* ] //" | ' // &
1400 'sed "s/remotes\\/origin\\///" | sort -u) | ' // &
1401 'fzf --height=15 --border=rounded --border-label=" ESC to cancel " ' // &
1402 '--prompt="Source branch: " ' // &
1403 '--preview="git log --oneline --graph --color=always {} | head -20" ' // &
1404 '--preview-window=right:50% > ' // FUSS_TEMP // '', &
1405 exitstat=status_code)
1406
1407 if (status_code /= 0) then
1408 call execute_command_line('rm -f ' // FUSS_TEMP // '', exitstat=status)
1409 print '(A)', ''
1410 print '(A)', 'Operation cancelled.'
1411 print '(A)', ''
1412 print '(A)', 'Press any key to continue...'
1413 call execute_command_line('read -n 1 -s < /dev/tty', exitstat=status)
1414 call execute_command_line('stty cbreak -echo < /dev/tty', exitstat=status)
1415 return
1416 end if
1417
1418 ! Read selected branch
1419 open(unit=99, file=FUSS_TEMP, status='old', action='read')
1420 read(99, '(A)', iostat=status) selected_branch
1421 close(99, status='delete')
1422
1423 if (status /= 0 .or. len_trim(selected_branch) == 0) then
1424 print '(A)', ''
1425 print '(A)', 'No branch selected.'
1426 print '(A)', ''
1427 print '(A)', 'Press any key to continue...'
1428 call execute_command_line('read -n 1 -s < /dev/tty', exitstat=status)
1429 call execute_command_line('stty cbreak -echo < /dev/tty', exitstat=status)
1430 return
1431 end if
1432
1433 ! Step 2: Select commit from that branch (only commits NOT in current branch)
1434 print '(A)', ''
1435 print '(A)', 'Select commit to cherry-pick from: ' // trim(selected_branch)
1436 print '(A)', ''
1437
1438 write(command, '(A,A,A)') '(git log ', trim(selected_branch), ' --not HEAD --oneline --color=always) | ' // &
1439 'fzf --height=20 --border=rounded --border-label=" ESC to cancel " ' // &
1440 '--prompt="Commit: " ' // &
1441 '--preview="git show --color=always {1}" ' // &
1442 '--preview-window=right:60% > ' // FUSS_TEMP // ''
1443 call execute_command_line(trim(command), exitstat=status_code)
1444
1445 if (status_code /= 0) then
1446 call execute_command_line('rm -f ' // FUSS_TEMP // '', exitstat=status)
1447 print '(A)', ''
1448 print '(A)', 'Operation cancelled.'
1449 print '(A)', ''
1450 print '(A)', 'Press any key to continue...'
1451 call execute_command_line('read -n 1 -s < /dev/tty', exitstat=status)
1452 call execute_command_line('stty cbreak -echo < /dev/tty', exitstat=status)
1453 return
1454 end if
1455
1456 ! Read selected commit (first 7 chars is the hash)
1457 open(unit=99, file=FUSS_TEMP, status='old', action='read')
1458 read(99, '(A)', iostat=status) selected_commit
1459 close(99, status='delete')
1460
1461 if (status /= 0 .or. len_trim(selected_commit) == 0) then
1462 print '(A)', ''
1463 print '(A)', 'No commit selected.'
1464 print '(A)', ''
1465 print '(A)', 'Press any key to continue...'
1466 call execute_command_line('read -n 1 -s < /dev/tty', exitstat=status)
1467 call execute_command_line('stty cbreak -echo < /dev/tty', exitstat=status)
1468 return
1469 end if
1470
1471 ! Extract commit hash (first word)
1472 selected_commit = selected_commit(1:7)
1473
1474 ! Step 3: Perform cherry-pick
1475 print '(A)', ''
1476 write(command, '(A,A,A)') 'git cherry-pick ', trim(selected_commit), ' 2>&1'
1477 call execute_command_line(trim(command), exitstat=status_code)
1478
1479 print '(A)', ''
1480 if (status_code == 0) then
1481 print '(A)', achar(27) // '[32m✓ Cherry-picked ' // trim(selected_commit) // achar(27) // '[0m'
1482 success = .true.
1483 else
1484 print '(A)', achar(27) // '[31m✗ Cherry-pick failed or has conflicts' // achar(27) // '[0m'
1485 print '(A)', 'Resolve conflicts, then run: git cherry-pick --continue'
1486 print '(A)', 'Or abort with: git cherry-pick --abort'
1487 end if
1488
1489 print '(A)', ''
1490 print '(A)', 'Press any key to continue...'
1491 call execute_command_line('read -n 1 -s < /dev/tty', exitstat=status)
1492
1493 ! Re-enable cbreak mode
1494 call execute_command_line('stty cbreak -echo < /dev/tty', exitstat=status)
1495 end subroutine git_cherry_pick
1496
1497 subroutine git_revert_commit(success)
1498 logical, intent(out) :: success
1499 integer :: status_code, status
1500 character(len=512) :: selected_commit
1501 character(len=2048) :: command
1502
1503 success = .false.
1504
1505 ! Restore terminal for fzf
1506 call execute_command_line('stty sane < /dev/tty', exitstat=status)
1507
1508 print '(A)', achar(27) // '[1mRevert Commit' // achar(27) // '[0m'
1509 print '(A)', ''
1510 print '(A)', 'Select commit to revert (creates a new commit that undoes changes):'
1511 print '(A)', ''
1512
1513 ! Select commit from history
1514 call execute_command_line('git log --oneline --color=always -n 100 | ' // &
1515 'fzf --height=20 --border=rounded --border-label=" ESC to cancel " ' // &
1516 '--prompt="Commit to revert: " ' // &
1517 '--preview="git show --color=always {1}" ' // &
1518 '--preview-window=right:60% > ' // FUSS_TEMP // '', &
1519 exitstat=status_code)
1520
1521 if (status_code /= 0) then
1522 call execute_command_line('rm -f ' // FUSS_TEMP // '', exitstat=status)
1523 print '(A)', ''
1524 print '(A)', 'Operation cancelled.'
1525 print '(A)', ''
1526 print '(A)', 'Press any key to continue...'
1527 call execute_command_line('read -n 1 -s < /dev/tty', exitstat=status)
1528 call execute_command_line('stty cbreak -echo < /dev/tty', exitstat=status)
1529 return
1530 end if
1531
1532 ! Read selected commit hash
1533 open(unit=99, file=FUSS_TEMP, status='old', action='read')
1534 read(99, '(A)', iostat=status) selected_commit
1535 close(99, status='delete')
1536
1537 if (status /= 0 .or. len_trim(selected_commit) == 0) then
1538 print '(A)', ''
1539 print '(A)', 'No commit selected.'
1540 print '(A)', ''
1541 print '(A)', 'Press any key to continue...'
1542 call execute_command_line('read -n 1 -s < /dev/tty', exitstat=status)
1543 call execute_command_line('stty cbreak -echo < /dev/tty', exitstat=status)
1544 return
1545 end if
1546
1547 ! Extract commit hash (first word)
1548 selected_commit = selected_commit(1:7)
1549
1550 ! Perform revert
1551 print '(A)', ''
1552 write(command, '(A,A,A)') 'git revert --no-edit ', trim(selected_commit), ' 2>&1'
1553 call execute_command_line(trim(command), exitstat=status_code)
1554
1555 print '(A)', ''
1556 if (status_code == 0) then
1557 print '(A)', achar(27) // '[32m✓ Reverted ' // trim(selected_commit) // achar(27) // '[0m'
1558 success = .true.
1559 else
1560 print '(A)', achar(27) // '[31m✗ Revert failed or has conflicts' // achar(27) // '[0m'
1561 print '(A)', 'Resolve conflicts, then run: git revert --continue'
1562 print '(A)', 'Or abort with: git revert --abort'
1563 end if
1564
1565 print '(A)', ''
1566 print '(A)', 'Press any key to continue...'
1567 call execute_command_line('read -n 1 -s < /dev/tty', exitstat=status)
1568
1569 ! Re-enable cbreak mode
1570 call execute_command_line('stty cbreak -echo < /dev/tty', exitstat=status)
1571 end subroutine git_revert_commit
1572
1573 subroutine git_show_history()
1574 integer :: status_code, status
1575 character(len=4096) :: command
1576 character(len=1) :: q, sq
1577
1578 ! Restore terminal for fzf
1579 call execute_command_line('stty sane < /dev/tty', exitstat=status)
1580
1581 ! Set up quote characters for easier reading
1582 q = achar(34) ! double quote "
1583 sq = achar(39) ! single quote '
1584
1585 ! Start with detailed view, allow switching with 1/2
1586 ! Build command carefully to avoid quote hell
1587 ! Detailed format: hash - relative date - message <author>
1588 command = 'git log --graph --color=always --all --pretty=' // sq // '%h - %ar - %s <%an>' // sq // ' | ' // &
1589 'fzf --ansi --height=100% --border=rounded ' // &
1590 '--border-label=' // q // ' History - Press 1:detailed 2:oneline ESC:close ' // q // ' ' // &
1591 '--prompt=' // q // 'Commit: ' // q // ' ' // &
1592 '--header=' // q // 'Switch views: 1=detailed 2=oneline' // q // ' ' // &
1593 '--preview=' // q // 'echo {} | grep -o ' // sq // '[0-9a-f]\{7,\}' // sq // &
1594 ' | head -1 | xargs git show --color=always' // q // ' ' // &
1595 '--preview-window=right:60% ' // &
1596 '--bind=' // q // '1:reload(git log --graph --color=always --all --pretty=' // sq // '%h - %ar - %s <%an>' // sq // ')' // q // ' ' // &
1597 '--bind=' // q // '2:reload(git log --oneline --graph --color=always --all)' // q // ' ' // &
1598 '> /dev/null'
1599
1600 call execute_command_line(trim(command), exitstat=status_code)
1601
1602 ! Re-enable cbreak mode
1603 call execute_command_line('stty cbreak -echo < /dev/tty', exitstat=status)
1604 end subroutine git_show_history
1605
1606 subroutine git_merge_branch(success)
1607 logical, intent(out) :: success
1608 integer :: status_code, status
1609 character(len=512) :: selected_branch
1610 character(len=2048) :: command
1611
1612 success = .false.
1613
1614 ! Restore terminal for fzf
1615 call execute_command_line('stty sane < /dev/tty', exitstat=status)
1616
1617 print '(A)', achar(27) // '[1mMerge Branch' // achar(27) // '[0m'
1618 print '(A)', ''
1619 print '(A)', 'Select branch to merge into current branch:'
1620 print '(A)', ''
1621
1622 ! Select branch (exclude current branch)
1623 call execute_command_line('(git branch --all | grep -v HEAD | grep -v "^\*" | sed "s/^[* ] //" | ' // &
1624 'sed "s/remotes\\/origin\\///" | sort -u) | ' // &
1625 'fzf --height=15 --border=rounded --border-label=" ESC to cancel " ' // &
1626 '--prompt="Branch to merge: " ' // &
1627 '--preview="echo Commits to merge:; git log --oneline --color=always HEAD..{} | head -20" ' // &
1628 '--preview-window=right:50% > ' // FUSS_TEMP // '', &
1629 exitstat=status_code)
1630
1631 if (status_code /= 0) then
1632 call execute_command_line('rm -f ' // FUSS_TEMP // '', exitstat=status)
1633 print '(A)', ''
1634 print '(A)', 'Operation cancelled.'
1635 print '(A)', ''
1636 print '(A)', 'Press any key to continue...'
1637 call execute_command_line('read -n 1 -s < /dev/tty', exitstat=status)
1638 call execute_command_line('stty cbreak -echo < /dev/tty', exitstat=status)
1639 return
1640 end if
1641
1642 ! Read selected branch
1643 open(unit=99, file=FUSS_TEMP, status='old', action='read')
1644 read(99, '(A)', iostat=status) selected_branch
1645 close(99, status='delete')
1646
1647 if (status /= 0 .or. len_trim(selected_branch) == 0) then
1648 print '(A)', ''
1649 print '(A)', 'No branch selected.'
1650 print '(A)', ''
1651 print '(A)', 'Press any key to continue...'
1652 call execute_command_line('read -n 1 -s < /dev/tty', exitstat=status)
1653 call execute_command_line('stty cbreak -echo < /dev/tty', exitstat=status)
1654 return
1655 end if
1656
1657 ! Perform merge
1658 print '(A)', ''
1659 print '(A)', 'Merging ' // trim(selected_branch) // ' into current branch...'
1660 print '(A)', ''
1661 write(command, '(A,A,A)') 'git merge ', trim(selected_branch), ' 2>&1'
1662 call execute_command_line(trim(command), exitstat=status_code)
1663
1664 print '(A)', ''
1665 if (status_code == 0) then
1666 print '(A)', achar(27) // '[32m✓ Merged ' // trim(selected_branch) // achar(27) // '[0m'
1667 success = .true.
1668 else
1669 print '(A)', achar(27) // '[31m✗ Merge failed or has conflicts' // achar(27) // '[0m'
1670 print '(A)', 'Resolve conflicts, then run: git merge --continue'
1671 print '(A)', 'Or abort with: git merge --abort'
1672 end if
1673
1674 print '(A)', ''
1675 print '(A)', 'Press any key to continue...'
1676 call execute_command_line('read -n 1 -s < /dev/tty', exitstat=status)
1677
1678 ! Re-enable cbreak mode
1679 call execute_command_line('stty cbreak -echo < /dev/tty', exitstat=status)
1680 end subroutine git_merge_branch
1681
1682 subroutine git_tag(tag_name, tag_message, success)
1683 character(len=*), intent(in) :: tag_name
1684 character(len=*), intent(in) :: tag_message
1685 logical, intent(out) :: success
1686 integer :: status
1687 character(len=2048) :: command
1688
1689 success = .false.
1690
1691 if (len_trim(tag_message) > 0) then
1692 ! Create annotated tag with message
1693 write(command, '(A,A,A,A,A)') 'git tag -a "', trim(tag_name), '" -m "', trim(tag_message), '" 2>&1'
1694 else
1695 ! Create lightweight tag (no message)
1696 write(command, '(A,A,A)') 'git tag "', trim(tag_name), '" 2>&1'
1697 end if
1698
1699 call execute_command_line(trim(command), exitstat=status)
1700
1701 if (status == 0) then
1702 print '(A)', achar(27) // '[32m✓ Tag created: ' // trim(tag_name) // achar(27) // '[0m'
1703 success = .true.
1704 else
1705 print '(A)', achar(27) // '[31m✗ Failed to create tag' // achar(27) // '[0m'
1706 end if
1707 end subroutine git_tag
1708
1709 subroutine git_switch_branch(success)
1710 logical, intent(out) :: success
1711 integer :: status_code
1712 character(len=512) :: selected_branch
1713 character(len=1024) :: command
1714
1715 success = .false.
1716
1717 ! Restore terminal for fzf
1718 call execute_command_line('stty sane < /dev/tty', exitstat=status_code)
1719
1720 ! Use fzf to select branch (local and remote)
1721 ! Show local branches and remote branches, remove leading spaces and origin/ prefix for display
1722 call execute_command_line('(git branch --all | grep -v HEAD | sed "s/^[* ] //" | sed "s/remotes\\/origin\\///" | sort -u) | ' // &
1723 'fzf --height=15 --border=rounded --border-label=" ESC to cancel " ' // &
1724 '--prompt="Switch to branch: " ' // &
1725 '--preview="git log --oneline --graph --color=always {}" ' // &
1726 '--preview-window=right:50% > ' // FUSS_TEMP // '', &
1727 exitstat=status_code)
1728
1729 ! Re-enable cbreak mode first
1730 call execute_command_line('stty cbreak -echo < /dev/tty', exitstat=status_code)
1731
1732 if (status_code /= 0) then
1733 ! User cancelled
1734 print '(A)', 'Branch switch cancelled.'
1735 print '(A)', ''
1736 print '(A)', 'Press any key to continue...'
1737 return
1738 end if
1739
1740 ! Read selected branch
1741 open(unit=99, file=FUSS_TEMP, status='old', action='read', iostat=status_code)
1742 if (status_code /= 0) then
1743 print '(A)', 'Branch switch cancelled.'
1744 print '(A)', ''
1745 print '(A)', 'Press any key to continue...'
1746 return
1747 end if
1748
1749 read(99, '(A)', iostat=status_code) selected_branch
1750 close(99, status='delete')
1751
1752 if (status_code /= 0 .or. len_trim(selected_branch) == 0) then
1753 print '(A)', 'Branch switch cancelled.'
1754 print '(A)', ''
1755 print '(A)', 'Press any key to continue...'
1756 return
1757 end if
1758
1759 ! Switch to the branch
1760 write(command, '(A,A,A)') 'git switch "', trim(selected_branch), '" 2>&1'
1761 call execute_command_line(trim(command), exitstat=status_code)
1762
1763 if (status_code == 0) then
1764 print '(A)', achar(27) // '[32m✓ Switched to branch: ' // trim(selected_branch) // achar(27) // '[0m'
1765 success = .true.
1766 else
1767 print '(A)', achar(27) // '[31m✗ Failed to switch branch' // achar(27) // '[0m'
1768 end if
1769
1770 print '(A)', ''
1771 print '(A)', 'Press any key to continue...'
1772 end subroutine git_switch_branch
1773
1774 subroutine git_create_branch(branch_name, success)
1775 character(len=*), intent(in) :: branch_name
1776 logical, intent(out) :: success
1777 character(len=1024) :: command
1778 integer :: status
1779
1780 success = .false.
1781
1782 if (len_trim(branch_name) == 0) then
1783 print '(A)', achar(27) // '[33mBranch name cannot be empty' // achar(27) // '[0m'
1784 return
1785 end if
1786
1787 ! Create and switch to new branch
1788 write(command, '(A,A,A)') 'git switch -c "', trim(branch_name), '" 2>&1'
1789 print '(A)', 'Creating new branch...'
1790 call execute_command_line(trim(command), exitstat=status)
1791
1792 if (status == 0) then
1793 print '(A)', achar(27) // '[32m✓ Created and switched to branch: ' // trim(branch_name) // achar(27) // '[0m'
1794 success = .true.
1795 else
1796 print '(A)', achar(27) // '[31m✗ Failed to create branch (may already exist)' // achar(27) // '[0m'
1797 end if
1798
1799 call execute_command_line('sleep 1', exitstat=status)
1800 end subroutine git_create_branch
1801
1802 subroutine git_delete_branch(success)
1803 use terminal_module, only: read_key
1804 logical, intent(out) :: success
1805 integer :: status_code
1806 character(len=512) :: selected_branch, current_branch
1807 character(len=1024) :: command
1808 character(len=1) :: key
1809
1810 success = .false.
1811
1812 ! Get current branch name to prevent deleting it
1813 call execute_command_line('git rev-parse --abbrev-ref HEAD > ' // FUSS_TEMP // ' 2>&1', &
1814 exitstat=status_code)
1815
1816 if (status_code /= 0) then
1817 print '(A)', achar(27) // '[31m✗ Could not determine current branch' // achar(27) // '[0m'
1818 return
1819 end if
1820
1821 open(unit=99, file=FUSS_TEMP, status='old', action='read', iostat=status_code)
1822 if (status_code == 0) then
1823 read(99, '(A)', iostat=status_code) current_branch
1824 close(99, status='delete')
1825 else
1826 return
1827 end if
1828
1829 ! Restore terminal for fzf
1830 call execute_command_line('stty sane < /dev/tty', exitstat=status_code)
1831
1832 ! Use fzf to select branch to delete (exclude current branch)
1833 write(command, '(A,A,A)') 'git branch | grep -v "^* " | sed "s/^ //" | grep -v "^', trim(current_branch), &
1834 '$" | fzf --height=15 --border=rounded --border-label=" ESC to cancel " --prompt="Delete branch: " > ' // FUSS_TEMP // ''
1835 call execute_command_line(trim(command), exitstat=status_code)
1836
1837 ! Re-enable cbreak mode
1838 call execute_command_line('stty cbreak -echo < /dev/tty', exitstat=status_code)
1839
1840 if (status_code /= 0) then
1841 ! User cancelled
1842 print '(A)', 'Branch deletion cancelled.'
1843 print '(A)', ''
1844 print '(A)', 'Press any key to continue...'
1845 return
1846 end if
1847
1848 ! Read selected branch
1849 open(unit=99, file=FUSS_TEMP, status='old', action='read', iostat=status_code)
1850 if (status_code /= 0) then
1851 print '(A)', 'Branch deletion cancelled.'
1852 print '(A)', ''
1853 print '(A)', 'Press any key to continue...'
1854 return
1855 end if
1856
1857 read(99, '(A)', iostat=status_code) selected_branch
1858 close(99, status='delete')
1859
1860 if (status_code /= 0 .or. len_trim(selected_branch) == 0) then
1861 print '(A)', 'Branch deletion cancelled.'
1862 print '(A)', ''
1863 print '(A)', 'Press any key to continue...'
1864 return
1865 end if
1866
1867 ! Confirm deletion
1868 print '(A)', ''
1869 print '(A)', 'Delete branch "' // trim(selected_branch) // '"?'
1870 print '(A)', 'Press ''d'' for regular delete, ''D'' to force delete, any other key to cancel.'
1871 call read_key(key)
1872
1873 if (key == 'd') then
1874 ! Regular delete (will fail if not merged)
1875 write(command, '(A,A,A)') 'git branch -d "', trim(selected_branch), '" 2>&1'
1876 print '(A)', 'Deleting branch...'
1877 call execute_command_line(trim(command), exitstat=status_code)
1878
1879 if (status_code == 0) then
1880 print '(A)', achar(27) // '[32m✓ Deleted branch: ' // trim(selected_branch) // achar(27) // '[0m'
1881 success = .true.
1882 else
1883 print '(A)', achar(27) // '[31m✗ Failed to delete (not fully merged? use D to force)' // achar(27) // '[0m'
1884 end if
1885 else if (key == 'D') then
1886 ! Force delete
1887 write(command, '(A,A,A)') 'git branch -D "', trim(selected_branch), '" 2>&1'
1888 print '(A)', 'Force deleting branch...'
1889 call execute_command_line(trim(command), exitstat=status_code)
1890
1891 if (status_code == 0) then
1892 print '(A)', achar(27) // '[32m✓ Force deleted branch: ' // trim(selected_branch) // achar(27) // '[0m'
1893 success = .true.
1894 else
1895 print '(A)', achar(27) // '[31m✗ Failed to delete branch' // achar(27) // '[0m'
1896 end if
1897 else
1898 print '(A)', 'Delete cancelled.'
1899 end if
1900
1901 print '(A)', ''
1902 print '(A)', 'Press any key to continue...'
1903 end subroutine git_delete_branch
1904
1905 subroutine git_push_tag(tag_name, success)
1906 character(len=*), intent(in) :: tag_name
1907 logical, intent(out) :: success
1908 character(len=1024) :: command
1909 integer :: status
1910
1911 success = .false.
1912
1913 ! Push specific tag to origin
1914 print '(A)', 'Pushing tag to origin...'
1915 write(command, '(A,A,A)') 'git push origin "', trim(tag_name), '"'
1916 call execute_command_line(trim(command), exitstat=status)
1917
1918 if (status == 0) then
1919 print '(A)', achar(27) // '[32m✓ Tag pushed to origin: ' // trim(tag_name) // achar(27) // '[0m'
1920 success = .true.
1921 else
1922 print '(A)', achar(27) // '[31m✗ Failed to push tag' // achar(27) // '[0m'
1923 end if
1924 end subroutine git_push_tag
1925
1926 subroutine git_stash_push(stash_message, success)
1927 character(len=*), intent(in) :: stash_message
1928 logical, intent(out) :: success
1929 character(len=2048) :: command
1930 integer :: status
1931
1932 success = .false.
1933
1934 ! Build stash push command with optional message
1935 if (len_trim(stash_message) > 0) then
1936 write(command, '(A,A,A)') 'git stash push -m "', trim(stash_message), '" 2>&1'
1937 else
1938 command = 'git stash push 2>&1'
1939 end if
1940
1941 print '(A)', 'Stashing changes...'
1942 call execute_command_line(trim(command), exitstat=status)
1943
1944 if (status == 0) then
1945 print '(A)', achar(27) // '[32m✓ Changes stashed successfully!' // achar(27) // '[0m'
1946 success = .true.
1947 else
1948 print '(A)', achar(27) // '[31m✗ Stash failed (no changes to stash?)' // achar(27) // '[0m'
1949 end if
1950
1951 print '(A)', 'Press any key to continue...'
1952 end subroutine git_stash_push
1953
1954 subroutine git_stash_pop_apply(success)
1955 use terminal_module, only: read_key
1956 logical, intent(out) :: success
1957 integer :: status_code
1958 character(len=512) :: selected_stash
1959 character(len=1024) :: command
1960 character(len=1) :: key
1961
1962 success = .false.
1963
1964 ! Check if there are any stashes
1965 call execute_command_line('git stash list > ' // FUSS_TEMP // ' 2>&1', exitstat=status_code)
1966 if (status_code /= 0) then
1967 print '(A)', achar(27) // '[33mNo stashes available' // achar(27) // '[0m'
1968 print '(A)', 'Press any key to continue...'
1969 return
1970 end if
1971
1972 ! Check if stash list is empty
1973 call execute_command_line('test -s ' // FUSS_TEMP // '', exitstat=status_code)
1974 if (status_code /= 0) then
1975 print '(A)', achar(27) // '[33mNo stashes available' // achar(27) // '[0m'
1976 print '(A)', 'Press any key to continue...'
1977 call execute_command_line('rm -f ' // FUSS_TEMP // '', exitstat=status_code)
1978 return
1979 end if
1980
1981 call execute_command_line('rm -f ' // FUSS_TEMP // '', exitstat=status_code)
1982
1983 ! Restore terminal for fzf
1984 call execute_command_line('stty sane < /dev/tty', exitstat=status_code)
1985
1986 ! Use fzf to select stash with preview
1987 call execute_command_line('git stash list --format="%gd: %s" | ' // &
1988 'fzf --height=15 --border=rounded --border-label=" ESC to cancel " ' // &
1989 '--prompt="Select stash: " ' // &
1990 '--preview="git stash show -p {1}" --preview-window=right:50% ' // &
1991 '> ' // FUSS_TEMP // '', &
1992 exitstat=status_code)
1993
1994 ! Re-enable cbreak mode
1995 call execute_command_line('stty cbreak -echo < /dev/tty', exitstat=status_code)
1996
1997 if (status_code /= 0) then
1998 ! User cancelled
1999 print '(A)', 'Stash operation cancelled.'
2000 print '(A)', ''
2001 print '(A)', 'Press any key to continue...'
2002 return
2003 end if
2004
2005 ! Read selected stash
2006 open(unit=99, file=FUSS_TEMP, status='old', action='read', iostat=status_code)
2007 if (status_code /= 0) then
2008 print '(A)', 'Stash operation cancelled.'
2009 print '(A)', ''
2010 print '(A)', 'Press any key to continue...'
2011 return
2012 end if
2013
2014 read(99, '(A)', iostat=status_code) selected_stash
2015 close(99, status='delete')
2016
2017 if (status_code /= 0 .or. len_trim(selected_stash) == 0) then
2018 print '(A)', 'Stash operation cancelled.'
2019 print '(A)', ''
2020 print '(A)', 'Press any key to continue...'
2021 return
2022 end if
2023
2024 ! Extract stash reference (e.g., "stash@{0}")
2025 ! Format is "stash@{N}: message", so we take everything before the first ":"
2026 command = selected_stash(1:index(selected_stash, ':') - 1)
2027
2028 ! Ask user whether to pop or apply
2029 print '(A)', ''
2030 print '(A)', 'Pop (apply and remove) or Apply (keep stash)?'
2031 print '(A)', 'Press ''p'' to pop, ''a'' to apply, any other key to cancel.'
2032 call read_key(key)
2033
2034 if (key == 'p' .or. key == 'P') then
2035 ! Pop the stash (apply and remove)
2036 print '(A)', 'Popping stash...'
2037 write(command, '(A,A,A)') 'git stash pop "', trim(command), '" 2>&1'
2038 call execute_command_line(trim(command), exitstat=status_code)
2039
2040 if (status_code == 0) then
2041 print '(A)', achar(27) // '[32m✓ Stash popped successfully!' // achar(27) // '[0m'
2042 success = .true.
2043 else
2044 print '(A)', achar(27) // '[31m✗ Stash pop failed (conflicts?)' // achar(27) // '[0m'
2045 end if
2046 else if (key == 'a' .or. key == 'A') then
2047 ! Apply the stash (keep it)
2048 print '(A)', 'Applying stash...'
2049 write(command, '(A,A,A)') 'git stash apply "', trim(command), '" 2>&1'
2050 call execute_command_line(trim(command), exitstat=status_code)
2051
2052 if (status_code == 0) then
2053 print '(A)', achar(27) // '[32m✓ Stash applied successfully!' // achar(27) // '[0m'
2054 success = .true.
2055 else
2056 print '(A)', achar(27) // '[31m✗ Stash apply failed (conflicts?)' // achar(27) // '[0m'
2057 end if
2058 else
2059 print '(A)', 'Stash operation cancelled.'
2060 end if
2061
2062 print '(A)', ''
2063 print '(A)', 'Press any key to continue...'
2064 end subroutine git_stash_pop_apply
2065
2066 subroutine git_blame_file(filepath)
2067 character(len=*), intent(in) :: filepath
2068 integer :: status_code, status
2069 character(len=4096) :: command
2070 character(len=1) :: q, sq
2071
2072 ! Restore terminal for fzf
2073 call execute_command_line('stty sane < /dev/tty', exitstat=status)
2074
2075 ! Set up quote characters
2076 q = achar(34) ! double quote "
2077 sq = achar(39) ! single quote '
2078
2079 ! Check if file is tracked by git
2080 call execute_command_line('git ls-files --error-unmatch "' // trim(filepath) // '" > /dev/null 2>&1', &
2081 exitstat=status_code)
2082 if (status_code /= 0) then
2083 print '(A)', achar(27) // '[31m✗ File is not tracked by git' // achar(27) // '[0m'
2084 print '(A)', ''
2085 print '(A)', 'Press any key to continue...'
2086 call execute_command_line('read -n 1 -s < /dev/tty', exitstat=status)
2087 call execute_command_line('stty cbreak -echo < /dev/tty', exitstat=status)
2088 return
2089 end if
2090
2091 ! Build fzf command with view toggling
2092 ! Start with compact view, allow switching with 1/2/3
2093 ! Compact: hash + line (no author/date)
2094 command = 'git blame -s --color-lines "' // trim(filepath) // '" | ' // &
2095 'fzf --ansi --height=100% --border=rounded ' // &
2096 '--border-label=' // q // ' Blame - Press 1:compact 2:detailed 3:full ESC:close ' // q // ' ' // &
2097 '--prompt=' // q // 'Who changed this? ' // q // ' ' // &
2098 '--header=' // q // 'Switch views: 1=compact 2=detailed 3=full' // q // ' ' // &
2099 '--preview=' // q // 'echo {} | grep -o ' // sq // '[0-9a-f]\{7,\}' // sq // &
2100 ' | head -1 | xargs git show --color=always' // q // ' ' // &
2101 '--preview-window=right:60% ' // &
2102 '--bind=' // q // '1:reload(git blame -s --color-lines ' // q // trim(filepath) // q // ')' // q // ' ' // &
2103 '--bind=' // q // '2:reload(git blame --color-lines ' // q // trim(filepath) // q // ')' // q // ' ' // &
2104 '--bind=' // q // '3:reload(git blame --color-by-age ' // q // trim(filepath) // q // ')' // q // ' ' // &
2105 '> /dev/null'
2106
2107 call execute_command_line(trim(command), exitstat=status_code)
2108
2109 ! Re-enable cbreak mode
2110 call execute_command_line('stty cbreak -echo < /dev/tty', exitstat=status)
2111 end subroutine git_blame_file
2112
2113 subroutine git_reset_interactive(success)
2114 logical, intent(out) :: success
2115 integer :: status_code, status
2116 character(len=512) :: selected_commit, reset_mode, confirmation
2117 character(len=2048) :: command
2118 character(len=1) :: mode_choice
2119
2120 success = .false.
2121
2122 ! Restore terminal for fzf
2123 call execute_command_line('stty sane < /dev/tty', exitstat=status)
2124
2125 ! Step 1: Select commit to reset to
2126 print '(A)', achar(27) // '[1mReset to Commit' // achar(27) // '[0m'
2127 print '(A)', ''
2128 print '(A)', 'Select commit to reset to:'
2129 print '(A)', ''
2130
2131 call execute_command_line('git log --oneline --color=always -n 50 | ' // &
2132 'fzf --height=15 --border=rounded --border-label=" ESC to cancel " ' // &
2133 '--prompt="Reset to: " ' // &
2134 '--preview="git show --stat --color=always {1}" ' // &
2135 '--preview-window=right:60% > ' // FUSS_TEMP // '', &
2136 exitstat=status_code)
2137
2138 if (status_code /= 0) then
2139 print '(A)', 'Reset cancelled.'
2140 print '(A)', ''
2141 print '(A)', 'Press any key to continue...'
2142 call execute_command_line('read -n 1 -s < /dev/tty', exitstat=status)
2143 call execute_command_line('stty cbreak -echo < /dev/tty', exitstat=status)
2144 return
2145 end if
2146
2147 ! Read selected commit
2148 open(unit=99, file=FUSS_TEMP, status='old', action='read', iostat=status)
2149 if (status /= 0) then
2150 print '(A)', achar(27) // '[31m✗ Failed to read selection' // achar(27) // '[0m'
2151 call execute_command_line('stty cbreak -echo < /dev/tty', exitstat=status)
2152 return
2153 end if
2154 read(99, '(A)', iostat=status) selected_commit
2155 close(99)
2156
2157 ! Extract just the commit hash (first word)
2158 read(selected_commit, *, iostat=status) selected_commit
2159
2160 ! Step 2: Select reset mode
2161 print '(A)', ''
2162 print '(A)', achar(27) // '[1mReset Mode' // achar(27) // '[0m'
2163 print '(A)', ''
2164 print '(A)', 'Choose reset mode:'
2165 print '(A)', ''
2166 print '(A)', achar(27) // '[32m 1' // achar(27) // '[0m - Soft (keep changes staged)'
2167 print '(A)', achar(27) // '[33m 2' // achar(27) // '[0m - Mixed (keep changes unstaged) [DEFAULT]'
2168 print '(A)', achar(27) // '[31m 3' // achar(27) // '[0m - Hard (DISCARD all changes - DANGEROUS!)'
2169 print '(A)', ''
2170 print '(A)', 'Enter choice (1/2/3) or ESC to cancel: '
2171
2172 ! Get mode choice
2173 call execute_command_line('read -n 1 choice < /dev/tty; echo $choice > ' // FUSS_TEMP // '', &
2174 exitstat=status)
2175
2176 ! Read mode choice
2177 open(unit=99, file=FUSS_TEMP, status='old', action='read', iostat=status)
2178 if (status /= 0) then
2179 print '(A)', 'Reset cancelled.'
2180 call execute_command_line('stty cbreak -echo < /dev/tty', exitstat=status)
2181 return
2182 end if
2183 read(99, '(A)', iostat=status) mode_choice
2184 close(99)
2185
2186 ! Determine reset mode
2187 select case (mode_choice)
2188 case ('1')
2189 reset_mode = '--soft'
2190 case ('2')
2191 reset_mode = '--mixed'
2192 case ('3')
2193 reset_mode = '--hard'
2194 ! Extra confirmation for hard reset
2195 print '(A)', ''
2196 print '(A)', achar(27) // '[1;31mWARNING: Hard reset will DESTROY all uncommitted changes!' // achar(27) // '[0m'
2197 print '(A)', achar(27) // '[1;31mThis operation CANNOT be undone!' // achar(27) // '[0m'
2198 print '(A)', ''
2199 print '(A)', 'Type "yes" to confirm hard reset: '
2200 call execute_command_line('read conf < /dev/tty; echo $conf > ' // FUSS_TEMP // '', &
2201 exitstat=status)
2202
2203 ! Read confirmation
2204 open(unit=99, file=FUSS_TEMP, status='old', action='read', iostat=status)
2205 if (status == 0) then
2206 read(99, '(A)', iostat=status) confirmation
2207 close(99)
2208 if (trim(confirmation) /= 'yes') then
2209 print '(A)', ''
2210 print '(A)', 'Hard reset cancelled (confirmation not received).'
2211 print '(A)', ''
2212 print '(A)', 'Press any key to continue...'
2213 call execute_command_line('read -n 1 -s < /dev/tty', exitstat=status)
2214 call execute_command_line('stty cbreak -echo < /dev/tty', exitstat=status)
2215 return
2216 end if
2217 else
2218 print '(A)', 'Reset cancelled.'
2219 call execute_command_line('stty cbreak -echo < /dev/tty', exitstat=status)
2220 return
2221 end if
2222 case default
2223 print '(A)', ''
2224 print '(A)', 'Reset cancelled.'
2225 print '(A)', ''
2226 print '(A)', 'Press any key to continue...'
2227 call execute_command_line('read -n 1 -s < /dev/tty', exitstat=status)
2228 call execute_command_line('stty cbreak -echo < /dev/tty', exitstat=status)
2229 return
2230 end select
2231
2232 ! Execute reset
2233 print '(A)', ''
2234 write(command, '(A,A,A,A,A)') 'git reset ', trim(reset_mode), ' ', trim(selected_commit), ' 2>&1'
2235 call execute_command_line(trim(command), exitstat=status_code)
2236
2237 if (status_code == 0) then
2238 print '(A)', achar(27) // '[32m✓ Reset to ' // trim(selected_commit) // ' (' // trim(reset_mode) // ')' // achar(27) // '[0m'
2239 success = .true.
2240 else
2241 print '(A)', achar(27) // '[31m✗ Reset failed' // achar(27) // '[0m'
2242 end if
2243
2244 print '(A)', ''
2245 print '(A)', 'Press any key to continue...'
2246 call execute_command_line('read -n 1 -s < /dev/tty', exitstat=status)
2247
2248 ! Re-enable cbreak mode
2249 call execute_command_line('stty cbreak -echo < /dev/tty', exitstat=status)
2250 end subroutine git_reset_interactive
2251
2252 subroutine git_show_reflog()
2253 integer :: status_code, status
2254 character(len=4096) :: command
2255 character(len=1) :: q, sq
2256
2257 ! Restore terminal for fzf
2258 call execute_command_line('stty sane < /dev/tty', exitstat=status)
2259
2260 ! Set up quote characters
2261 q = achar(34) ! double quote "
2262 sq = achar(39) ! single quote '
2263
2264 ! Start with detailed view, allow switching with 1/2/3
2265 ! Detailed format: HEAD@{n} hash - action
2266 command = 'git reflog --format=' // sq // '%gd %h - (%ar) %gs' // sq // ' | ' // &
2267 'fzf --ansi --height=100% --border=rounded ' // &
2268 '--border-label=' // q // ' Reflog - Press 1:detailed 2:oneline 3:all ESC:close ' // q // ' ' // &
2269 '--prompt=' // q // 'Reflog: ' // q // ' ' // &
2270 '--header=' // q // 'Switch views: 1=detailed 2=oneline 3=all-reflogs' // q // ' ' // &
2271 '--preview=' // q // 'echo {} | grep -o ' // sq // '[0-9a-f]\{7,\}' // sq // &
2272 ' | head -1 | xargs git show --color=always' // q // ' ' // &
2273 '--preview-window=right:60% ' // &
2274 '--bind=' // q // '1:reload(git reflog --format=' // sq // '%gd %h - (%ar) %gs' // sq // ')' // q // ' ' // &
2275 '--bind=' // q // '2:reload(git reflog --oneline)' // q // ' ' // &
2276 '--bind=' // q // '3:reload(git reflog show --all --format=' // sq // '%gd %h - (%ar) %gs' // sq // ')' // q // ' ' // &
2277 '> /dev/null'
2278
2279 call execute_command_line(trim(command), exitstat=status_code)
2280
2281 ! Re-enable cbreak mode
2282 call execute_command_line('stty cbreak -echo < /dev/tty', exitstat=status)
2283 end subroutine git_show_reflog
2284
2285 subroutine git_interactive_rebase(success)
2286 logical, intent(out) :: success
2287 integer :: status_code, status
2288 character(len=512) :: selected_commit
2289 character(len=2048) :: command
2290
2291 success = .false.
2292
2293 ! Restore terminal for fzf
2294 call execute_command_line('stty sane < /dev/tty', exitstat=status)
2295
2296 ! Check if we have unpushed commits
2297 call execute_command_line('git log @{upstream}.. --oneline > /dev/null 2>&1', exitstat=status_code)
2298 if (status_code /= 0) then
2299 print '(A)', achar(27) // '[33mWarning: No upstream branch configured' // achar(27) // '[0m'
2300 print '(A)', 'Rebasing without checking if commits are pushed.'
2301 print '(A)', ''
2302 end if
2303
2304 ! Select base commit for rebase
2305 print '(A)', achar(27) // '[1mInteractive Rebase' // achar(27) // '[0m'
2306 print '(A)', ''
2307 print '(A)', achar(27) // '[33mWarning: Only rebase commits that have NOT been pushed!' // achar(27) // '[0m'
2308 print '(A)', ''
2309 print '(A)', 'Select base commit (commits after this will be rebased):'
2310 print '(A)', ''
2311
2312 call execute_command_line('git log --oneline --color=always -n 50 | ' // &
2313 'fzf --height=15 --border=rounded --border-label=" ESC to cancel " ' // &
2314 '--prompt="Rebase from: " ' // &
2315 '--preview="git log --oneline --color=always {1}~1..HEAD | head -20" ' // &
2316 '--preview-window=right:60% ' // &
2317 '--preview-label=" Commits that will be rebased " ' // &
2318 '> ' // FUSS_TEMP // '', &
2319 exitstat=status_code)
2320
2321 if (status_code /= 0) then
2322 print '(A)', 'Rebase cancelled.'
2323 print '(A)', ''
2324 print '(A)', 'Press any key to continue...'
2325 call execute_command_line('read -n 1 -s < /dev/tty', exitstat=status)
2326 call execute_command_line('stty cbreak -echo < /dev/tty', exitstat=status)
2327 return
2328 end if
2329
2330 ! Read selected commit
2331 open(unit=99, file=FUSS_TEMP, status='old', action='read', iostat=status)
2332 if (status /= 0) then
2333 print '(A)', achar(27) // '[31m✗ Failed to read selection' // achar(27) // '[0m'
2334 call execute_command_line('stty cbreak -echo < /dev/tty', exitstat=status)
2335 return
2336 end if
2337 read(99, '(A)', iostat=status) selected_commit
2338 close(99)
2339
2340 ! Extract just the commit hash (first word)
2341 read(selected_commit, *, iostat=status) selected_commit
2342
2343 ! Show what will be rebased
2344 print '(A)', ''
2345 print '(A)', achar(27) // '[1mCommits that will be rebased:' // achar(27) // '[0m'
2346 write(command, '(A,A,A)') 'git log --oneline --color=always ', trim(selected_commit), '..HEAD'
2347 call execute_command_line(trim(command), exitstat=status)
2348
2349 print '(A)', ''
2350 print '(A)', 'Your editor will open with the rebase plan.'
2351 print '(A)', 'Edit the file to reorder/squash/drop commits, then save and exit.'
2352 print '(A)', ''
2353 print '(A)', 'Press Enter to continue or Ctrl+C to cancel...'
2354 call execute_command_line('read dummy < /dev/tty', exitstat=status)
2355
2356 ! Execute interactive rebase
2357 ! Git will open the editor automatically
2358 write(command, '(A,A)') 'git rebase -i ', trim(selected_commit)
2359 call execute_command_line(trim(command), exitstat=status_code)
2360
2361 if (status_code == 0) then
2362 print '(A)', ''
2363 print '(A)', achar(27) // '[32m✓ Rebase completed successfully!' // achar(27) // '[0m'
2364 success = .true.
2365 else
2366 print '(A)', ''
2367 print '(A)', achar(27) // '[31m✗ Rebase failed or was aborted' // achar(27) // '[0m'
2368 print '(A)', ''
2369 print '(A)', 'If you have conflicts:'
2370 print '(A)', ' - Resolve conflicts in the files'
2371 print '(A)', ' - git add <resolved files>'
2372 print '(A)', ' - git rebase --continue'
2373 print '(A)', ''
2374 print '(A)', 'Or to abort: git rebase --abort'
2375 end if
2376
2377 print '(A)', ''
2378 print '(A)', 'Press any key to continue...'
2379 call execute_command_line('read -n 1 -s < /dev/tty', exitstat=status)
2380
2381 ! Re-enable cbreak mode
2382 call execute_command_line('stty cbreak -echo < /dev/tty', exitstat=status)
2383 end subroutine git_interactive_rebase
2384
2385 end module git_module
2386