Text · 29605 bytes Raw Blame History
1 program fortress_clean
2 use iso_fortran_env, only: output_unit, error_unit
3 implicit none
4
5 ! Constants
6 integer, parameter :: MAX_PATH = 512
7 integer, parameter :: MAX_FILES = 500
8
9 character(len=*), parameter :: ESC = char(27)
10 character(len=*), parameter :: CLEAR = ESC // "[2J" // ESC // "[H"
11 character(len=*), parameter :: BOLD = ESC // "[1m"
12 character(len=*), parameter :: DIM = ESC // "[2m"
13 character(len=*), parameter :: REVERSE = ESC // "[7m"
14 character(len=*), parameter :: RESET = ESC // "[0m"
15 character(len=*), parameter :: BLUE = ESC // "[34m"
16 character(len=*), parameter :: GREEN = ESC // "[32m"
17 character(len=*), parameter :: RED = ESC // "[31m"
18 character(len=*), parameter :: GREY = ESC // "[90m"
19 character(len=*), parameter :: WHITE = ESC // "[37m"
20
21 ! Variables
22 character(len=MAX_PATH) :: current_dir, parent_dir, temp_dir
23 character(len=MAX_PATH), dimension(MAX_FILES) :: current_files, parent_files
24 logical, dimension(MAX_FILES) :: current_is_dir, parent_is_dir
25 logical, dimension(MAX_FILES) :: current_is_exec, parent_is_exec
26 logical, dimension(MAX_FILES) :: current_is_staged, current_is_unstaged, current_is_untracked
27 logical, dimension(MAX_FILES) :: parent_is_staged, parent_is_unstaged, parent_is_untracked
28 integer :: current_count, parent_count
29 integer :: selected = 1
30 integer :: parent_selected = -1
31 integer :: scroll_offset = 0
32 integer :: parent_scroll_offset = 0
33 character(len=1) :: key
34 logical :: running = .true.
35 logical :: cd_on_exit = .false.
36 character(len=MAX_PATH) :: exit_dir
37 character(len=256) :: repo_name
38 character(len=256) :: branch_name
39 logical :: in_git_repo = .false.
40 integer :: i, rows, cols, visible_height
41
42 ! Initialize
43 current_dir = get_pwd()
44 parent_dir = get_parent_path(current_dir)
45 call detect_git_repo(current_dir, in_git_repo, repo_name, branch_name)
46
47 ! Setup terminal
48 call execute_command_line("stty -icanon -echo min 1 time 0 2>/dev/null")
49
50 ! Main loop
51 do while (running)
52 ! Get files
53 call get_file_list(current_dir, current_files, current_is_dir, current_is_exec, current_count)
54 call get_file_list(parent_dir, parent_files, parent_is_dir, parent_is_exec, parent_count)
55
56 ! Initialize git arrays - only for actual file counts
57 do i = 1, current_count
58 current_is_staged(i) = .false.
59 current_is_unstaged(i) = .false.
60 current_is_untracked(i) = .false.
61 end do
62 do i = 1, parent_count
63 parent_is_staged(i) = .false.
64 parent_is_unstaged(i) = .false.
65 parent_is_untracked(i) = .false.
66 end do
67
68 ! Get git status if in a repo
69 if (in_git_repo) then
70 call get_git_status(current_dir, current_files, current_count, &
71 current_is_staged, current_is_unstaged, current_is_untracked)
72 end if
73
74 ! Get terminal size early to use for scroll calculations
75 call get_term_size(rows, cols)
76 visible_height = rows - 3 ! Header + footer + 1 for indexing
77
78 ! Handle navigation - find position in parent if needed
79 if (selected == -1) then
80 selected = find_in_parent(temp_dir, current_files, current_count)
81 ! Center the cursor in viewport if possible
82 scroll_offset = max(0, selected - visible_height / 2)
83 else if (selected == -2) then
84 ! Find position after fzf selection
85 selected = find_file_in_list(temp_dir, current_files, current_count)
86 ! Center the cursor in viewport
87 scroll_offset = max(0, selected - visible_height / 2)
88 end if
89
90 ! Ensure selected cursor is within valid bounds
91 if (current_count > 0) then
92 selected = max(1, min(selected, current_count))
93 else
94 selected = 1
95 end if
96
97 ! Find current dir in parent
98 parent_selected = find_in_parent(current_dir, parent_files, parent_count)
99
100 ! Adjust scroll offset to keep selected item visible
101 if (selected <= scroll_offset) then
102 ! Scrolled above viewport - move viewport up
103 scroll_offset = max(0, selected - 1)
104 else if (selected > scroll_offset + visible_height) then
105 ! Scrolled below viewport - move viewport down
106 scroll_offset = selected - visible_height
107 end if
108 scroll_offset = max(0, min(scroll_offset, max(0, current_count - visible_height)))
109
110 ! Adjust parent scroll offset to keep parent selection visible
111 if (parent_selected > 0) then
112 if (parent_selected <= parent_scroll_offset) then
113 parent_scroll_offset = max(0, parent_selected - 1)
114 else if (parent_selected > parent_scroll_offset + visible_height) then
115 parent_scroll_offset = parent_selected - visible_height
116 end if
117 parent_scroll_offset = max(0, min(parent_scroll_offset, max(0, parent_count - visible_height)))
118 end if
119
120 ! Draw interface
121 write(output_unit, '(a)', advance='no') CLEAR
122 call draw_interface(rows, cols)
123
124 ! Get input
125 read(*, '(a1)', advance='no') key
126
127 ! Handle input
128 select case(ichar(key))
129 case(27) ! ESC sequence
130 call read_arrow_key(key)
131 select case(key)
132 case('A') ! Up
133 if (selected > 1) then
134 selected = selected - 1
135 end if
136 case('B') ! Down
137 if (selected < current_count .and. current_count > 0) then
138 selected = selected + 1
139 end if
140 case('C') ! Right - enter
141 if (current_is_dir(selected)) then
142 if (trim(current_files(selected)) == "..") then
143 temp_dir = current_dir
144 current_dir = parent_dir
145 parent_dir = get_parent_path(current_dir)
146 selected = -1 ! Signal to find position in parent
147 ! Re-detect git repo after navigation
148 call detect_git_repo(current_dir, in_git_repo, repo_name, branch_name)
149 else if (trim(current_files(selected)) /= ".") then
150 parent_dir = current_dir
151 current_dir = join_path(current_dir, current_files(selected))
152 selected = 1
153 scroll_offset = 0
154 ! Re-detect git repo after entering directory
155 call detect_git_repo(current_dir, in_git_repo, repo_name, branch_name)
156 end if
157 end if
158 case('D') ! Left - back
159 if (current_dir /= "/") then
160 temp_dir = current_dir
161 current_dir = parent_dir
162 parent_dir = get_parent_path(current_dir)
163 selected = -1 ! Signal to find position in parent
164 ! Re-detect git repo after going back
165 call detect_git_repo(current_dir, in_git_repo, repo_name, branch_name)
166 end if
167 end select
168 case(113, 81) ! 'q' or 'Q'
169 running = .false.
170 case(99, 67) ! 'c' or 'C' - cd to directory on exit
171 if (current_is_dir(selected)) then
172 if (trim(current_files(selected)) == "..") then
173 exit_dir = parent_dir
174 else if (trim(current_files(selected)) == ".") then
175 exit_dir = current_dir
176 else
177 exit_dir = join_path(current_dir, current_files(selected))
178 end if
179 cd_on_exit = .true.
180 running = .false.
181 end if
182 case(102, 70) ! 'f' or 'F' - fzf search
183 call fzf_search(current_dir, temp_dir)
184 if (len_trim(temp_dir) > 0) then
185 ! Navigate to the selected file's directory
186 parent_dir = get_parent_path(temp_dir)
187 current_dir = parent_dir
188 parent_dir = get_parent_path(current_dir)
189 selected = -2 ! Signal to find and center on fzf result
190 ! Re-detect git repo after fzf navigation
191 call detect_git_repo(current_dir, in_git_repo, repo_name, branch_name)
192 end if
193 case(65, 97) ! 'A' or 'a' - git add
194 if (in_git_repo .and. .not. current_is_dir(selected)) then
195 if (trim(current_files(selected)) /= "." .and. trim(current_files(selected)) /= "..") then
196 call git_add_file(current_dir, current_files(selected))
197 end if
198 end if
199 case(77, 109) ! 'M' or 'm' - git commit
200 if (in_git_repo) then
201 call git_commit_prompt(current_dir)
202 end if
203 case(85, 117) ! 'U' or 'u' - git unstage (restore --staged)
204 if (in_git_repo .and. .not. current_is_dir(selected)) then
205 if (trim(current_files(selected)) /= "." .and. trim(current_files(selected)) /= "..") then
206 ! Only unstage if file is actually staged
207 if (current_is_staged(selected)) then
208 call git_unstage_file(current_dir, current_files(selected))
209 end if
210 end if
211 end if
212 end select
213 end do
214
215 ! Cleanup
216 call execute_command_line("stty icanon echo 2>/dev/null")
217 write(output_unit, '(a)', advance='no') CLEAR
218
219 ! If cd_on_exit is set, write the directory to a temp file
220 if (cd_on_exit) then
221 call write_exit_dir(exit_dir)
222 else
223 write(output_unit, '(a)') "Thanks for using FORTRESS!"
224 end if
225
226 contains
227
228 function get_pwd() result(path)
229 character(len=MAX_PATH) :: path
230 integer :: unit, ios
231
232 call execute_command_line("pwd > .fortress_pwd 2>/dev/null", wait=.true.)
233 open(newunit=unit, file=".fortress_pwd", status='old', iostat=ios)
234 if (ios == 0) then
235 read(unit, '(a)') path
236 close(unit)
237 else
238 path = "."
239 end if
240 call execute_command_line("rm -f .fortress_pwd 2>/dev/null")
241 end function get_pwd
242
243 function get_parent_path(path) result(parent)
244 character(len=*), intent(in) :: path
245 character(len=MAX_PATH) :: parent
246 integer :: pos
247
248 pos = index(path, "/", back=.true.)
249 if (pos > 1) then
250 parent = path(1:pos-1)
251 else if (pos == 1) then
252 parent = "/"
253 else
254 parent = "."
255 end if
256 end function get_parent_path
257
258 function join_path(base, name) result(full)
259 character(len=*), intent(in) :: base, name
260 character(len=MAX_PATH) :: full
261
262 if (base == "/") then
263 full = "/" // trim(name)
264 else
265 full = trim(base) // "/" // trim(name)
266 end if
267 end function join_path
268
269 function find_in_parent(dir, files, count) result(idx)
270 character(len=*), intent(in) :: dir
271 character(len=*), dimension(*), intent(in) :: files
272 integer, intent(in) :: count
273 integer :: idx, pos
274 character(len=256) :: basename
275
276 pos = index(dir, "/", back=.true.)
277 if (pos > 0) then
278 basename = dir(pos+1:)
279 else
280 basename = dir
281 end if
282
283 do idx = 1, count
284 if (trim(files(idx)) == trim(basename)) return
285 end do
286 idx = 1
287 end function find_in_parent
288
289 subroutine get_file_list(dir, files, is_dir, is_exec, count)
290 character(len=*), intent(in) :: dir
291 character(len=*), dimension(*), intent(out) :: files
292 logical, dimension(*), intent(out) :: is_dir, is_exec
293 integer, intent(out) :: count
294 integer :: unit, ios, stat
295 character(len=MAX_PATH) :: fullpath
296
297 call execute_command_line("ls -1a '" // trim(dir) // "' > .fortress_ls 2>/dev/null", wait=.true.)
298
299 open(newunit=unit, file=".fortress_ls", status='old', iostat=ios)
300 if (ios /= 0) then
301 count = 0
302 return
303 end if
304
305 count = 0
306 do
307 count = count + 1
308 if (count > MAX_FILES) exit
309 read(unit, '(a)', iostat=ios) files(count)
310 if (ios /= 0) then
311 count = count - 1
312 exit
313 end if
314
315 fullpath = join_path(dir, files(count))
316 call execute_command_line("test -d '" // trim(fullpath) // "'", exitstat=stat, wait=.true.)
317 is_dir(count) = (stat == 0)
318
319 ! Check if executable (but not directories)
320 if (.not. is_dir(count)) then
321 call execute_command_line("test -x '" // trim(fullpath) // "'", exitstat=stat, wait=.true.)
322 is_exec(count) = (stat == 0)
323 else
324 is_exec(count) = .false.
325 end if
326 end do
327
328 close(unit)
329 call execute_command_line("rm -f .fortress_ls 2>/dev/null")
330 end subroutine get_file_list
331
332 subroutine get_term_size(r, c)
333 integer, intent(out) :: r, c
334 integer :: unit, ios
335
336 call execute_command_line("tput lines > .fortress_size 2>/dev/null", wait=.true.)
337 open(newunit=unit, file=".fortress_size", status='old', iostat=ios)
338 if (ios == 0) then
339 read(unit, *) r
340 close(unit)
341 else
342 r = 24
343 end if
344
345 call execute_command_line("tput cols > .fortress_size 2>/dev/null", wait=.true.)
346 open(newunit=unit, file=".fortress_size", status='old', iostat=ios)
347 if (ios == 0) then
348 read(unit, *) c
349 close(unit)
350 else
351 c = 80
352 end if
353
354 call execute_command_line("rm -f .fortress_size 2>/dev/null")
355 end subroutine get_term_size
356
357 subroutine draw_interface(r, c)
358 integer, intent(in) :: r, c
359 integer :: left_w, i, parent_idx, current_idx, vis_h
360 character(len=256) :: fname
361 character(len=20) :: color_code
362
363 left_w = c * 3 / 10
364 vis_h = r - 3 ! Visible height
365
366 ! Header
367 write(output_unit, '(a)') BOLD // "FORTRESS" // RESET // " - " // trim(current_dir)
368
369 ! Files (render based on scroll offsets)
370 do i = 1, vis_h
371 parent_idx = i + parent_scroll_offset
372 current_idx = i + scroll_offset
373
374 ! Parent pane
375 if (parent_idx >= 1 .and. parent_idx <= parent_count) then
376 fname = parent_files(parent_idx)
377 if (parent_is_dir(parent_idx) .and. fname /= "." .and. fname /= "..") then
378 fname = trim(fname) // "/"
379 end if
380
381 ! Get color for parent file
382 color_code = get_file_color(parent_files(parent_idx), parent_is_dir(parent_idx), parent_is_exec(parent_idx))
383
384 if (parent_idx == parent_selected) then
385 write(output_unit, '(a)', advance='no') DIM // BOLD // trim(color_code) // &
386 fname(1:min(len_trim(fname),left_w)) // RESET
387 else
388 write(output_unit, '(a)', advance='no') DIM // trim(color_code) // &
389 fname(1:min(len_trim(fname),left_w)) // RESET
390 end if
391 write(output_unit, '(a)', advance='no') repeat(" ", max(0, left_w - len_trim(fname)))
392 else
393 write(output_unit, '(a)', advance='no') repeat(" ", left_w)
394 end if
395
396 ! Separator
397 write(output_unit, '(a)', advance='no') " │ "
398
399 ! Current pane
400 if (current_idx >= 1 .and. current_idx <= current_count) then
401 fname = current_files(current_idx)
402 if (current_is_dir(current_idx) .and. fname /= "." .and. fname /= "..") then
403 fname = trim(fname) // "/"
404 end if
405
406 ! Get color for current file
407 color_code = get_file_color(current_files(current_idx), current_is_dir(current_idx), current_is_exec(current_idx))
408
409 if (current_idx == selected) then
410 write(output_unit, '(a)', advance='no') REVERSE // trim(color_code) // trim(fname)
411 ! Add git indicators if in repo
412 if (in_git_repo) then
413 call write_git_indicators(current_is_staged(current_idx), &
414 current_is_unstaged(current_idx), &
415 current_is_untracked(current_idx), .true.)
416 end if
417 write(output_unit, '(a)') RESET
418 else
419 write(output_unit, '(a)', advance='no') trim(color_code) // trim(fname)
420 ! Add git indicators if in repo
421 if (in_git_repo) then
422 call write_git_indicators(current_is_staged(current_idx), &
423 current_is_unstaged(current_idx), &
424 current_is_untracked(current_idx), .false.)
425 end if
426 write(output_unit, '(a)') RESET
427 end if
428 else
429 write(output_unit, *)
430 end if
431 end do
432
433 ! Footer
434 if (in_git_repo) then
435 write(output_unit, '(a)') DIM // trim(repo_name) // ":" // trim(branch_name) // " | " // RESET // &
436 DIM // "↑↓:nav →:enter ←:back f:find A:add U:unstage M:commit c:cd q:quit" // RESET
437 else
438 write(output_unit, '(a)') DIM // "↑↓:nav →:enter ←:back f:find c:cd q:quit" // RESET
439 end if
440 end subroutine draw_interface
441
442 subroutine read_arrow_key(k)
443 character(len=1), intent(out) :: k
444 character(len=1) :: ch
445
446 read(*, '(a1)', advance='no') ch
447 if (ch == '[') then
448 read(*, '(a1)', advance='no') k
449 else
450 k = ch
451 end if
452 end subroutine read_arrow_key
453
454 function get_file_color(filename, is_dir, is_exec) result(color)
455 character(len=*), intent(in) :: filename
456 logical, intent(in) :: is_dir, is_exec
457 character(len=20) :: color
458
459 ! Directories: Blue and bold
460 if (is_dir) then
461 color = BOLD // BLUE
462 ! Dotfiles: Grey
463 else if (filename(1:1) == '.') then
464 color = GREY
465 ! Executable files: Green
466 else if (is_exec) then
467 color = GREEN
468 ! All other files: White
469 else
470 color = WHITE
471 end if
472 end function get_file_color
473
474 subroutine write_exit_dir(dir)
475 character(len=*), intent(in) :: dir
476 character(len=MAX_PATH) :: temp_file
477 integer :: unit, ios
478
479 ! Create temp file in HOME directory
480 call get_environment_variable("HOME", temp_file)
481 temp_file = trim(temp_file) // "/.fortress_cd"
482
483 open(newunit=unit, file=temp_file, status='replace', action='write', iostat=ios)
484 if (ios == 0) then
485 write(unit, '(a)') trim(dir)
486 close(unit)
487 end if
488 end subroutine write_exit_dir
489
490 subroutine fzf_search(search_dir, result_path)
491 character(len=*), intent(in) :: search_dir
492 character(len=*), intent(out) :: result_path
493 character(len=MAX_PATH) :: temp_file, fzf_cmd
494 integer :: unit, ios, stat
495
496 result_path = ""
497
498 ! Create temp file for fzf output
499 call get_environment_variable("HOME", temp_file)
500 temp_file = trim(temp_file) // "/.fortress_fzf"
501
502 ! Restore terminal for fzf
503 call execute_command_line("stty icanon echo 2>/dev/null")
504
505 ! Build fzf command: find files, pipe to fzf, save selection
506 fzf_cmd = "cd '" // trim(search_dir) // "' && " // &
507 "find . -type f -o -type d | " // &
508 "sed 's|^\./||' | " // &
509 "fzf --height=40% --reverse --border --preview 'ls -lh {}' " // &
510 "> " // trim(temp_file) // " 2>/dev/null"
511
512 ! Run fzf
513 call execute_command_line(trim(fzf_cmd), exitstat=stat, wait=.true.)
514
515 ! Restore raw mode
516 call execute_command_line("stty -icanon -echo min 1 time 0 2>/dev/null")
517
518 ! Read result if fzf succeeded
519 if (stat == 0) then
520 open(newunit=unit, file=temp_file, status='old', iostat=ios)
521 if (ios == 0) then
522 read(unit, '(a)', iostat=ios) result_path
523 if (ios == 0) then
524 ! Convert relative path to absolute
525 result_path = join_path(search_dir, result_path)
526 end if
527 close(unit)
528 end if
529 end if
530
531 ! Cleanup
532 call execute_command_line("rm -f " // trim(temp_file) // " 2>/dev/null")
533 end subroutine fzf_search
534
535 function find_file_in_list(target_path, files, count) result(idx)
536 character(len=*), intent(in) :: target_path
537 character(len=*), dimension(*), intent(in) :: files
538 integer, intent(in) :: count
539 integer :: idx, pos
540 character(len=MAX_PATH) :: basename
541
542 ! Extract basename from target_path
543 pos = index(target_path, "/", back=.true.)
544 if (pos > 0) then
545 basename = target_path(pos+1:)
546 else
547 basename = target_path
548 end if
549
550 ! Search for the file in the list
551 do idx = 1, count
552 if (trim(files(idx)) == trim(basename)) return
553 end do
554
555 ! Default to first item if not found
556 idx = 1
557 end function find_file_in_list
558
559 subroutine detect_git_repo(dir, is_git, repo, branch)
560 character(len=*), intent(in) :: dir
561 logical, intent(out) :: is_git
562 character(len=*), intent(out) :: repo, branch
563 integer :: stat
564 character(len=MAX_PATH) :: temp_file
565
566 is_git = .false.
567 repo = ""
568 branch = ""
569
570 ! Check if .git directory exists
571 call execute_command_line("git -C '" // trim(dir) // "' rev-parse --git-dir > /dev/null 2>&1", &
572 exitstat=stat, wait=.true.)
573 is_git = (stat == 0)
574
575 if (is_git) then
576 ! Get repo name (basename of repo root)
577 call get_environment_variable("HOME", temp_file)
578 temp_file = trim(temp_file) // "/.fortress_repo"
579 call execute_command_line("git -C '" // trim(dir) // "' rev-parse --show-toplevel 2>/dev/null | " // &
580 "xargs basename > " // trim(temp_file), wait=.true.)
581 open(newunit=stat, file=temp_file, status='old', iostat=i)
582 if (i == 0) then
583 read(stat, '(a)', iostat=i) repo
584 close(stat)
585 end if
586 call execute_command_line("rm -f " // trim(temp_file) // " 2>/dev/null")
587
588 ! Get current branch name
589 temp_file = trim(temp_file) // "_branch"
590 call execute_command_line("git -C '" // trim(dir) // "' rev-parse --abbrev-ref HEAD 2>/dev/null > " // &
591 trim(temp_file), wait=.true.)
592 open(newunit=stat, file=temp_file, status='old', iostat=i)
593 if (i == 0) then
594 read(stat, '(a)', iostat=i) branch
595 close(stat)
596 end if
597 call execute_command_line("rm -f " // trim(temp_file) // " 2>/dev/null")
598 end if
599 end subroutine detect_git_repo
600
601 subroutine get_git_status(dir, files, count, is_staged, is_unstaged, is_untracked)
602 character(len=*), intent(in) :: dir
603 character(len=*), dimension(*), intent(in) :: files
604 integer, intent(in) :: count
605 logical, dimension(*), intent(out) :: is_staged, is_unstaged, is_untracked
606 character(len=MAX_PATH) :: temp_file, line, file_path, git_status
607 integer :: unit, ios, stat, i, j
608 character(len=MAX_PATH) :: full_path
609
610 ! Initialize all to false
611 do i = 1, count
612 is_staged(i) = .false.
613 is_unstaged(i) = .false.
614 is_untracked(i) = .false.
615 end do
616
617 ! Get git status
618 call get_environment_variable("HOME", temp_file)
619 temp_file = trim(temp_file) // "/.fortress_git_status"
620 call execute_command_line("cd '" // trim(dir) // "' && git status --porcelain 2>/dev/null > " // &
621 trim(temp_file), exitstat=stat, wait=.true.)
622
623 if (stat /= 0) return
624
625 ! Parse git status output
626 open(newunit=unit, file=temp_file, status='old', iostat=ios)
627 if (ios /= 0) return
628
629 do
630 read(unit, '(a)', iostat=ios) line
631 if (ios /= 0) exit
632
633 if (len_trim(line) > 3) then
634 git_status = line(1:2)
635 file_path = trim(adjustl(line(4:)))
636
637 ! Match against our file list
638 do i = 1, count
639 if (trim(files(i)) == trim(file_path)) then
640 ! Parse git status (XY format)
641 is_untracked(i) = (git_status == '??')
642 is_staged(i) = (git_status(1:1) /= ' ' .and. git_status(1:1) /= '?')
643 is_unstaged(i) = (git_status(2:2) /= ' ' .and. .not. is_untracked(i))
644 exit
645 end if
646 end do
647 end if
648 end do
649
650 close(unit)
651 call execute_command_line("rm -f " // trim(temp_file) // " 2>/dev/null")
652 end subroutine get_git_status
653
654 subroutine write_git_indicators(staged, unstaged, untracked, highlighted)
655 logical, intent(in) :: staged, unstaged, untracked, highlighted
656
657 ! Write indicators without RESET (caller handles that)
658 if (staged) then
659 if (highlighted) then
660 write(output_unit, '(a)', advance='no') GREEN // " ↑"
661 else
662 write(output_unit, '(a)', advance='no') GREEN // " ↑" // RESET
663 end if
664 end if
665 if (unstaged) then
666 if (highlighted) then
667 write(output_unit, '(a)', advance='no') RED // " ✗"
668 else
669 write(output_unit, '(a)', advance='no') RED // " ✗" // RESET
670 end if
671 end if
672 if (untracked) then
673 if (highlighted) then
674 write(output_unit, '(a)', advance='no') GREY // " ✗"
675 else
676 write(output_unit, '(a)', advance='no') GREY // " ✗" // RESET
677 end if
678 end if
679 end subroutine write_git_indicators
680
681 subroutine git_add_file(dir, filename)
682 character(len=*), intent(in) :: dir, filename
683 character(len=MAX_PATH*2) :: git_cmd
684 integer :: stat
685
686 ! Build git add command
687 git_cmd = "cd '" // trim(dir) // "' && git add '" // trim(filename) // "' 2>/dev/null"
688 call execute_command_line(trim(git_cmd), exitstat=stat, wait=.true.)
689
690 ! Note: git status will be refreshed in the next main loop iteration
691 end subroutine git_add_file
692
693 subroutine git_unstage_file(dir, filename)
694 character(len=*), intent(in) :: dir, filename
695 character(len=MAX_PATH*2) :: git_cmd
696 integer :: stat
697
698 ! Build git restore --staged command
699 git_cmd = "cd '" // trim(dir) // "' && git restore --staged '" // trim(filename) // "' 2>/dev/null"
700 call execute_command_line(trim(git_cmd), exitstat=stat, wait=.true.)
701
702 ! Note: git status will be refreshed in the next main loop iteration
703 end subroutine git_unstage_file
704
705 subroutine git_commit_prompt(dir)
706 character(len=*), intent(in) :: dir
707 character(len=512) :: commit_msg
708 character(len=MAX_PATH*2) :: git_cmd
709 integer :: stat, ios
710
711 ! Clear screen and show prompt
712 write(output_unit, '(a)', advance='no') CLEAR
713 write(output_unit, '(a)', advance='no') BOLD // "Git Commit" // RESET // " - " // trim(repo_name)
714 write(output_unit, *)
715 write(output_unit, *)
716 write(output_unit, '(a)', advance='no') "Commit message: "
717
718 ! Restore terminal to canonical mode for reading input
719 call execute_command_line("stty icanon echo 2>/dev/null")
720
721 ! Read commit message
722 read(*, '(a)', iostat=ios) commit_msg
723
724 ! Restore raw mode
725 call execute_command_line("stty -icanon -echo min 1 time 0 2>/dev/null")
726
727 if (ios == 0 .and. len_trim(commit_msg) > 0) then
728 ! Execute git commit (use single quotes for message to avoid escaping issues)
729 git_cmd = "cd '" // trim(dir) // "' && git commit -m '" // trim(commit_msg) // "' 2>&1"
730 call execute_command_line(trim(git_cmd), exitstat=stat, wait=.true.)
731
732 ! Show result briefly
733 write(output_unit, *)
734 if (stat == 0) then
735 write(output_unit, '(a)') GREEN // "✓ Committed successfully!" // RESET
736 else
737 write(output_unit, '(a)') RED // "✗ Commit failed (nothing to commit?)" // RESET
738 end if
739 write(output_unit, '(a)') "Press any key to continue..."
740
741 ! Wait for keypress
742 read(*, '(a1)', advance='no') key
743 end if
744 end subroutine git_commit_prompt
745
746 end program fortress_clean