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