Fortran · 29232 bytes Raw Blame History
1 module git_ops
2 use iso_fortran_env, only: output_unit
3 use terminal_control, only: GREEN, RED, GREY, YELLOW, RESET, BOLD, CLEAR
4 use filesystem_ops, only: MAX_PATH, MAX_FILES
5 implicit none
6 private
7
8 public :: detect_git_repo, get_git_status, write_git_indicators
9 public :: git_add_file, git_unstage_file, git_commit_prompt
10 public :: git_push_prompt, git_tag_prompt, prompt_upstream_selection
11 public :: show_git_diff_fullscreen
12 public :: invalidate_git_cache
13 public :: git_fetch_prompt, git_pull_prompt
14 public :: mark_incoming_changes
15
16 ! Cache variables for git status
17 logical :: cache_valid = .false.
18 character(len=MAX_PATH) :: cached_dir = ""
19 integer :: cache_timestamp = 0
20 integer :: cache_count = 0
21 character(len=MAX_PATH), dimension(MAX_FILES) :: cached_files
22 logical, dimension(MAX_FILES) :: cached_staged, cached_unstaged, cached_untracked
23 integer, parameter :: CACHE_TTL_MS = 500 ! Cache for 500ms
24
25 contains
26
27 subroutine detect_git_repo(dir, is_git, repo, branch)
28 character(len=*), intent(in) :: dir
29 logical, intent(out) :: is_git
30 character(len=*), intent(out) :: repo, branch
31 integer :: stat, i
32 character(len=MAX_PATH) :: temp_file
33
34 is_git = .false.
35 repo = ""
36 branch = ""
37
38 ! Check if .git directory exists
39 call execute_command_line("git -C '" // trim(dir) // "' rev-parse --git-dir > /dev/null 2>&1", &
40 exitstat=stat, wait=.true.)
41 is_git = (stat == 0)
42
43 if (is_git) then
44 ! Get repo name (basename of repo root)
45 call get_environment_variable("HOME", temp_file)
46 temp_file = trim(temp_file) // "/.fortress_repo"
47 call execute_command_line("git -C '" // trim(dir) // "' rev-parse --show-toplevel 2>/dev/null | " // &
48 "xargs basename > " // trim(temp_file), wait=.true.)
49 open(newunit=stat, file=temp_file, status='old', iostat=i)
50 if (i == 0) then
51 read(stat, '(a)', iostat=i) repo
52 close(stat)
53 end if
54 call execute_command_line("rm -f " // trim(temp_file) // " 2>/dev/null")
55
56 ! Get current branch name
57 temp_file = trim(temp_file) // "_branch"
58 call execute_command_line("git -C '" // trim(dir) // "' rev-parse --abbrev-ref HEAD 2>/dev/null > " // &
59 trim(temp_file), wait=.true.)
60 open(newunit=stat, file=temp_file, status='old', iostat=i)
61 if (i == 0) then
62 read(stat, '(a)', iostat=i) branch
63 close(stat)
64 end if
65 call execute_command_line("rm -f " // trim(temp_file) // " 2>/dev/null")
66 end if
67 end subroutine detect_git_repo
68
69 subroutine invalidate_git_cache()
70 cache_valid = .false.
71 end subroutine invalidate_git_cache
72
73 integer function get_time_ms()
74 integer :: values(8)
75 call date_and_time(values=values)
76 ! Convert to milliseconds (rough approximation, good enough for cache)
77 get_time_ms = values(5)*3600000 + values(6)*60000 + values(7)*1000 + values(8)
78 end function get_time_ms
79
80 logical function is_cache_valid(dir, files, count)
81 character(len=*), intent(in) :: dir
82 character(len=*), dimension(*), intent(in) :: files
83 integer, intent(in) :: count
84 integer :: current_time, i
85 logical :: files_match
86
87 is_cache_valid = .false.
88
89 ! Check if cache exists and directory matches
90 if (.not. cache_valid .or. trim(cached_dir) /= trim(dir)) return
91
92 ! Check if cache is still fresh (within TTL)
93 current_time = get_time_ms()
94 if (abs(current_time - cache_timestamp) > CACHE_TTL_MS) return
95
96 ! Check if file list matches (quick count check first)
97 if (cache_count /= count) return
98
99 ! Verify files are the same
100 files_match = .true.
101 do i = 1, count
102 if (trim(cached_files(i)) /= trim(files(i))) then
103 files_match = .false.
104 exit
105 end if
106 end do
107
108 is_cache_valid = files_match
109 end function is_cache_valid
110
111 subroutine get_git_status(dir, files, is_dir_arr, count, is_staged, is_unstaged, is_untracked)
112 character(len=*), intent(in) :: dir
113 character(len=*), dimension(*), intent(in) :: files
114 logical, dimension(*), intent(in) :: is_dir_arr
115 integer, intent(in) :: count
116 logical, dimension(*), intent(out) :: is_staged, is_unstaged, is_untracked
117 character(len=MAX_PATH) :: temp_file, line, file_path, git_status
118 character(len=MAX_PATH) :: repo_root, rel_path, full_status_path
119 integer :: unit, ios, stat, i, repo_root_len
120
121 ! Check if we can use cached data
122 if (is_cache_valid(dir, files, count)) then
123 ! Use cached results
124 do i = 1, count
125 is_staged(i) = cached_staged(i)
126 is_unstaged(i) = cached_unstaged(i)
127 is_untracked(i) = cached_untracked(i)
128 end do
129 return
130 end if
131
132 ! Initialize all to false
133 do i = 1, count
134 is_staged(i) = .false.
135 is_unstaged(i) = .false.
136 is_untracked(i) = .false.
137 end do
138
139 ! Get repo root
140 call get_environment_variable("HOME", temp_file)
141 temp_file = trim(temp_file) // "/.fortress_repo_root"
142 call execute_command_line("git -C '" // trim(dir) // "' rev-parse --show-toplevel 2>/dev/null > " // &
143 trim(temp_file), exitstat=stat, wait=.true.)
144 if (stat /= 0) return
145
146 open(newunit=unit, file=temp_file, status='old', iostat=ios)
147 if (ios == 0) then
148 read(unit, '(a)', iostat=ios) repo_root
149 close(unit)
150 end if
151 call execute_command_line("rm -f " // trim(temp_file) // " 2>/dev/null")
152 if (ios /= 0) return
153
154 ! Calculate relative path from repo root to current directory
155 repo_root_len = len_trim(repo_root)
156 if (trim(dir) == trim(repo_root)) then
157 rel_path = ""
158 else if (len_trim(dir) > repo_root_len .and. dir(1:repo_root_len) == trim(repo_root)) then
159 ! Remove repo_root + "/" from dir
160 rel_path = dir(repo_root_len+2:) ! +2 to skip the "/"
161 else
162 rel_path = ""
163 end if
164
165 ! Get git status from repo root
166 temp_file = trim(temp_file) // "_status"
167 call execute_command_line("cd '" // trim(repo_root) // "' && git status --porcelain 2>/dev/null > " // &
168 trim(temp_file), exitstat=stat, wait=.true.)
169
170 if (stat /= 0) return
171
172 ! Parse git status output
173 open(newunit=unit, file=temp_file, status='old', iostat=ios)
174 if (ios /= 0) return
175
176 do
177 read(unit, '(a)', iostat=ios) line
178 if (ios /= 0) exit
179
180 if (len_trim(line) > 3) then
181 git_status = line(1:2)
182 full_status_path = trim(adjustl(line(4:)))
183
184 ! Check if this file is in our current directory or subdirectory
185 do i = 1, count
186 ! Build expected path for this file
187 if (len_trim(rel_path) > 0) then
188 file_path = trim(rel_path) // "/" // trim(files(i))
189 else
190 file_path = trim(files(i))
191 end if
192
193 ! For files: exact match
194 if (.not. is_dir_arr(i) .and. trim(full_status_path) == trim(file_path)) then
195 is_untracked(i) = (git_status == '??')
196 is_staged(i) = (git_status(1:1) /= ' ' .and. git_status(1:1) /= '?')
197 is_unstaged(i) = (git_status(2:2) /= ' ' .and. .not. is_untracked(i))
198 exit
199 end if
200
201 ! For directories: check if status path starts with dirname/
202 if (is_dir_arr(i) .and. trim(files(i)) /= "." .and. trim(files(i)) /= "..") then
203 ! Build directory path
204 if (len_trim(rel_path) > 0) then
205 file_path = trim(rel_path) // "/" // trim(files(i)) // "/"
206 else
207 file_path = trim(files(i)) // "/"
208 end if
209
210 ! Check if status path starts with this directory
211 if (len_trim(full_status_path) >= len_trim(file_path) .and. &
212 full_status_path(1:len_trim(file_path)) == trim(file_path)) then
213 ! This directory contains dirty files
214 is_untracked(i) = is_untracked(i) .or. (git_status == '??')
215 is_staged(i) = is_staged(i) .or. (git_status(1:1) /= ' ' .and. git_status(1:1) /= '?')
216 is_unstaged(i) = is_unstaged(i) .or. (git_status(2:2) /= ' ' .and. git_status /= '??')
217 end if
218 end if
219 end do
220 end if
221 end do
222
223 close(unit)
224 call execute_command_line("rm -f " // trim(temp_file) // " 2>/dev/null")
225
226 ! Update cache
227 cache_valid = .true.
228 cached_dir = dir
229 cache_timestamp = get_time_ms()
230 cache_count = count
231 do i = 1, count
232 cached_files(i) = files(i)
233 cached_staged(i) = is_staged(i)
234 cached_unstaged(i) = is_unstaged(i)
235 cached_untracked(i) = is_untracked(i)
236 end do
237 end subroutine get_git_status
238
239 subroutine write_git_indicators(staged, unstaged, untracked, has_incoming, highlighted)
240 logical, intent(in) :: staged, unstaged, untracked, has_incoming, highlighted
241
242 ! When highlighted (cursor), don't change colors - just write symbols
243 ! When not highlighted, use colors with immediate RESET
244 if (staged) then
245 if (highlighted) then
246 write(output_unit, '(a)', advance='no') " ↑"
247 else
248 write(output_unit, '(a)', advance='no') GREEN // " ↑" // RESET
249 end if
250 end if
251 if (unstaged) then
252 if (highlighted) then
253 write(output_unit, '(a)', advance='no') " ✗"
254 else
255 write(output_unit, '(a)', advance='no') RED // " ✗" // RESET
256 end if
257 end if
258 if (untracked) then
259 if (highlighted) then
260 write(output_unit, '(a)', advance='no') " ✗"
261 else
262 write(output_unit, '(a)', advance='no') GREY // " ✗" // RESET
263 end if
264 end if
265 if (has_incoming) then
266 if (highlighted) then
267 write(output_unit, '(a)', advance='no') " ↓"
268 else
269 write(output_unit, '(a)', advance='no') YELLOW // " ↓" // RESET
270 end if
271 end if
272 end subroutine write_git_indicators
273
274 subroutine git_add_file(dir, filename)
275 character(len=*), intent(in) :: dir, filename
276 character(len=MAX_PATH*2) :: git_cmd
277 integer :: stat
278
279 ! Build git add command
280 git_cmd = "cd '" // trim(dir) // "' && git add '" // trim(filename) // "' 2>/dev/null"
281 call execute_command_line(trim(git_cmd), exitstat=stat, wait=.true.)
282
283 ! Invalidate cache after git operation
284 call invalidate_git_cache()
285 end subroutine git_add_file
286
287 subroutine git_unstage_file(dir, filename)
288 character(len=*), intent(in) :: dir, filename
289 character(len=MAX_PATH*2) :: git_cmd
290 integer :: stat
291
292 ! Build git restore --staged command
293 git_cmd = "cd '" // trim(dir) // "' && git restore --staged '" // trim(filename) // "' 2>/dev/null"
294 call execute_command_line(trim(git_cmd), exitstat=stat, wait=.true.)
295
296 ! Invalidate cache after git operation
297 call invalidate_git_cache()
298 end subroutine git_unstage_file
299
300 subroutine git_commit_prompt(dir, repo_name)
301 character(len=*), intent(in) :: dir, repo_name
302 character(len=512) :: commit_msg
303 character(len=MAX_PATH*2) :: git_cmd
304 character(len=1) :: key
305 integer :: stat, ios
306
307 ! Clear screen and show prompt
308 write(output_unit, '(a)', advance='no') CLEAR
309 write(output_unit, '(a)', advance='no') BOLD // "Git Commit" // RESET // " - " // trim(repo_name)
310 write(output_unit, *)
311 write(output_unit, *)
312 write(output_unit, '(a)', advance='no') "Commit message: "
313
314 ! Restore terminal to canonical mode for reading input
315 call execute_command_line("stty icanon echo 2>/dev/null")
316
317 ! Read commit message
318 read(*, '(a)', iostat=ios) commit_msg
319
320 ! Restore raw mode
321 call execute_command_line("stty -icanon -echo min 1 time 0 2>/dev/null", wait=.true.)
322
323 if (ios == 0 .and. len_trim(commit_msg) > 0) then
324 ! Execute git commit (use single quotes for message to avoid escaping issues)
325 git_cmd = "cd '" // trim(dir) // "' && git commit -m '" // trim(commit_msg) // "' 2>&1"
326 call execute_command_line(trim(git_cmd), exitstat=stat, wait=.true.)
327
328 ! Show result briefly
329 write(output_unit, *)
330 if (stat == 0) then
331 write(output_unit, '(a)') GREEN // "✓ Committed successfully!" // RESET
332 else
333 write(output_unit, '(a)') RED // "✗ Commit failed (nothing to commit?)" // RESET
334 end if
335 write(output_unit, '(a)') "Press any key to continue..."
336
337 ! Wait for keypress
338 read(*, '(a1)', advance='no') key
339 end if
340 end subroutine git_commit_prompt
341
342 subroutine prompt_upstream_selection(dir, success)
343 character(len=*), intent(in) :: dir
344 logical, intent(out) :: success
345 character(len=MAX_PATH) :: temp_file, selected_branch
346 integer :: stat, unit, ios
347
348 success = .false.
349
350 ! Clear screen and show prompt
351 write(output_unit, '(a)', advance='no') CLEAR
352 write(output_unit, '(a)') BOLD // "No upstream branch configured" // RESET
353 write(output_unit, *)
354 write(output_unit, '(a)') "Select a remote branch to track:"
355 write(output_unit, *)
356
357 ! Restore terminal for fzf
358 call execute_command_line("stty sane 2>/dev/null")
359
360 ! Use fzf to select remote branch
361 call get_environment_variable("HOME", temp_file)
362 temp_file = trim(temp_file) // "/.fortress_upstream"
363 call execute_command_line("cd '" // trim(dir) // "' && git branch -r | grep -v HEAD | " // &
364 "sed 's/^[[:space:]]*//' | fzf --height=10 --prompt='Select upstream: ' > " // &
365 trim(temp_file) // " 2>/dev/null", exitstat=stat, wait=.true.)
366
367 if (stat /= 0) then
368 write(output_unit, '(a)') RED // "No upstream selected." // RESET
369 call execute_command_line("sleep 1")
370 ! Re-enable raw mode
371 call execute_command_line("stty -icanon -echo min 1 time 0 2>/dev/null", wait=.true.)
372 call execute_command_line("rm -f " // trim(temp_file) // " 2>/dev/null")
373 return
374 end if
375
376 ! Read selected branch
377 open(newunit=unit, file=temp_file, status='old', action='read', iostat=ios)
378 if (ios == 0) then
379 read(unit, '(a)', iostat=ios) selected_branch
380 close(unit)
381
382 if (ios == 0 .and. len_trim(selected_branch) > 0) then
383 ! Set upstream
384 call execute_command_line("cd '" // trim(dir) // "' && git branch --set-upstream-to=" // &
385 trim(selected_branch) // " 2>&1", exitstat=stat, wait=.true.)
386
387 if (stat == 0) then
388 write(output_unit, '(a)') GREEN // "✓ Upstream set to: " // trim(selected_branch) // RESET
389 success = .true.
390 else
391 write(output_unit, '(a)') RED // "✗ Failed to set upstream" // RESET
392 end if
393 call execute_command_line("sleep 1")
394 end if
395 end if
396
397 call execute_command_line("rm -f " // trim(temp_file) // " 2>/dev/null")
398 ! Re-enable raw mode
399 call execute_command_line("stty -icanon -echo min 1 time 0 2>/dev/null", wait=.true.)
400 end subroutine prompt_upstream_selection
401
402 subroutine git_push_prompt(dir, repo_name)
403 character(len=*), intent(in) :: dir, repo_name
404 character(len=MAX_PATH*2) :: git_cmd
405 character(len=1) :: key
406 integer :: stat
407 logical :: upstream_set
408
409 ! Clear screen and show prompt
410 write(output_unit, '(a)', advance='no') CLEAR
411 write(output_unit, '(a)', advance='no') BOLD // "Git Push" // RESET // " - " // trim(repo_name)
412 write(output_unit, *)
413 write(output_unit, *)
414
415 ! Check if upstream is configured
416 call execute_command_line("cd '" // trim(dir) // "' && git rev-parse --abbrev-ref @{upstream} " // &
417 "> /dev/null 2>&1", exitstat=stat, wait=.true.)
418
419 if (stat /= 0) then
420 ! No upstream configured - prompt user to select one
421 call prompt_upstream_selection(dir, upstream_set)
422 if (.not. upstream_set) return
423 ! Clear screen again after upstream selection
424 write(output_unit, '(a)', advance='no') CLEAR
425 write(output_unit, '(a)', advance='no') BOLD // "Git Push" // RESET // " - " // trim(repo_name)
426 write(output_unit, *)
427 write(output_unit, *)
428 end if
429
430 write(output_unit, '(a)') "Pushing to remote..."
431 write(output_unit, *)
432
433 ! Execute git push
434 git_cmd = "cd '" // trim(dir) // "' && git push 2>&1"
435 call execute_command_line(trim(git_cmd), exitstat=stat, wait=.true.)
436
437 ! Show result
438 write(output_unit, *)
439 if (stat == 0) then
440 write(output_unit, '(a)') GREEN // "✓ Pushed successfully!" // RESET
441 else
442 write(output_unit, '(a)') RED // "✗ Push failed" // RESET
443 end if
444 write(output_unit, '(a)') "Press any key to continue..."
445
446 ! Wait for keypress
447 read(*, '(a1)', advance='no') key
448 end subroutine git_push_prompt
449
450 subroutine git_tag_prompt(dir, repo_name)
451 character(len=*), intent(in) :: dir, repo_name
452 character(len=512) :: tag_name, tag_message
453 character(len=MAX_PATH*2) :: git_cmd
454 character(len=1) :: key
455 integer :: stat, ios
456
457 ! Clear screen and show prompt
458 write(output_unit, '(a)', advance='no') CLEAR
459 write(output_unit, '(a)', advance='no') BOLD // "Git Tag" // RESET // " - " // trim(repo_name)
460 write(output_unit, *)
461 write(output_unit, *)
462 write(output_unit, '(a)', advance='no') "Tag name: "
463
464 ! Restore terminal to canonical mode for reading input
465 call execute_command_line("stty icanon echo 2>/dev/null")
466
467 ! Read tag name
468 read(*, '(a)', iostat=ios) tag_name
469
470 if (ios == 0 .and. len_trim(tag_name) > 0) then
471 ! Read tag message (optional)
472 write(output_unit, '(a)', advance='no') "Tag message (enter for none): "
473 read(*, '(a)', iostat=ios) tag_message
474
475 ! Restore raw mode
476 call execute_command_line("stty -icanon -echo min 1 time 0 2>/dev/null", wait=.true.)
477
478 if (ios == 0) then
479 ! Execute git tag
480 if (len_trim(tag_message) > 0) then
481 ! Create annotated tag with message
482 git_cmd = "cd '" // trim(dir) // "' && git tag -a '" // trim(tag_name) // &
483 "' -m '" // trim(tag_message) // "' 2>&1"
484 else
485 ! Create lightweight tag (no message)
486 git_cmd = "cd '" // trim(dir) // "' && git tag '" // trim(tag_name) // "' 2>&1"
487 end if
488
489 call execute_command_line(trim(git_cmd), exitstat=stat, wait=.true.)
490
491 ! Show result
492 write(output_unit, *)
493 if (stat == 0) then
494 write(output_unit, '(a)') GREEN // "✓ Tag created: " // trim(tag_name) // RESET
495 else
496 write(output_unit, '(a)') RED // "✗ Failed to create tag" // RESET
497 end if
498 write(output_unit, '(a)') "Press any key to continue..."
499
500 ! Wait for keypress
501 read(*, '(a1)', advance='no') key
502 end if
503 else
504 ! Restore raw mode if tag name was empty
505 call execute_command_line("stty -icanon -echo min 1 time 0 2>/dev/null", wait=.true.)
506 end if
507 end subroutine git_tag_prompt
508
509 subroutine show_git_diff_fullscreen(dir, filename, is_staged, is_unstaged)
510 character(len=*), intent(in) :: dir, filename
511 logical, intent(in) :: is_staged, is_unstaged
512 character(len=MAX_PATH*2) :: git_cmd
513 character(len=1) :: key
514 integer :: ios
515
516 ! Clear screen
517 write(output_unit, '(a)', advance='no') CLEAR
518 write(output_unit, '(a)') BOLD // "Git Diff" // RESET // " - " // trim(filename)
519 write(output_unit, *)
520
521 ! Restore terminal to normal mode for better output
522 call execute_command_line("stty sane 2>/dev/null")
523
524 ! Build and execute diff command (show both staged and unstaged if both exist)
525 if (is_unstaged) then
526 write(output_unit, '(a)') BOLD // "Unstaged changes:" // RESET
527 git_cmd = "cd '" // trim(dir) // "' && git diff --color=always '" // trim(filename) // "' 2>&1"
528 call execute_command_line(trim(git_cmd), wait=.true.)
529 write(output_unit, *)
530 end if
531
532 if (is_staged) then
533 write(output_unit, '(a)') BOLD // "Staged changes:" // RESET
534 git_cmd = "cd '" // trim(dir) // "' && git diff --cached --color=always '" // trim(filename) // "' 2>&1"
535 call execute_command_line(trim(git_cmd), wait=.true.)
536 write(output_unit, *)
537 end if
538
539 ! Wait for keypress
540 write(output_unit, *)
541 write(output_unit, '(a)') GREY // "Press any key to return..." // RESET
542
543 ! Restore raw mode and give terminal time to settle
544 call execute_command_line("stty -icanon -echo min 1 time 0 2>/dev/null && sleep 0.05", wait=.true.)
545
546 ! Read keypress with error handling for any buffering issues
547 read(*, '(a1)', advance='no', iostat=ios) key
548 end subroutine show_git_diff_fullscreen
549
550 subroutine git_fetch_prompt(dir, repo_name)
551 character(len=*), intent(in) :: dir, repo_name
552 character(len=1) :: key
553 integer :: stat
554 logical :: upstream_set
555
556 ! Clear screen and show prompt
557 write(output_unit, '(a)', advance='no') CLEAR
558 write(output_unit, '(a)', advance='no') BOLD // "Git Fetch" // RESET // " - " // trim(repo_name)
559 write(output_unit, *)
560 write(output_unit, *)
561
562 ! Check if upstream is configured
563 call execute_command_line("cd '" // trim(dir) // "' && git rev-parse --abbrev-ref @{upstream} " // &
564 "> /dev/null 2>&1", exitstat=stat, wait=.true.)
565
566 if (stat /= 0) then
567 ! No upstream configured - prompt user to select one
568 call prompt_upstream_selection(dir, upstream_set)
569 if (.not. upstream_set) return
570 ! Clear screen again after upstream selection
571 write(output_unit, '(a)', advance='no') CLEAR
572 write(output_unit, '(a)', advance='no') BOLD // "Git Fetch" // RESET // " - " // trim(repo_name)
573 write(output_unit, *)
574 write(output_unit, *)
575 end if
576
577 write(output_unit, '(a)') "Fetching from remote..."
578 write(output_unit, *)
579
580 ! Execute git fetch
581 call execute_command_line("cd '" // trim(dir) // "' && git fetch 2>&1", exitstat=stat, wait=.true.)
582
583 ! Show result
584 write(output_unit, *)
585 if (stat == 0) then
586 write(output_unit, '(a)') GREEN // "✓ Fetch completed!" // RESET
587 else
588 write(output_unit, '(a)') RED // "✗ Fetch failed" // RESET
589 end if
590 write(output_unit, '(a)') "Press any key to continue..."
591
592 ! Wait for keypress
593 read(*, '(a1)', advance='no') key
594
595 ! Invalidate git cache after fetch
596 call invalidate_git_cache()
597 end subroutine git_fetch_prompt
598
599 subroutine git_pull_prompt(dir, repo_name)
600 character(len=*), intent(in) :: dir, repo_name
601 character(len=1) :: key
602 integer :: stat
603 logical :: upstream_set
604
605 ! Clear screen and show prompt
606 write(output_unit, '(a)', advance='no') CLEAR
607 write(output_unit, '(a)', advance='no') BOLD // "Git Pull" // RESET // " - " // trim(repo_name)
608 write(output_unit, *)
609 write(output_unit, *)
610
611 ! Check if upstream is configured
612 call execute_command_line("cd '" // trim(dir) // "' && git rev-parse --abbrev-ref @{upstream} " // &
613 "> /dev/null 2>&1", exitstat=stat, wait=.true.)
614
615 if (stat /= 0) then
616 ! No upstream configured - prompt user to select one
617 call prompt_upstream_selection(dir, upstream_set)
618 if (.not. upstream_set) return
619 ! Clear screen again after upstream selection
620 write(output_unit, '(a)', advance='no') CLEAR
621 write(output_unit, '(a)', advance='no') BOLD // "Git Pull" // RESET // " - " // trim(repo_name)
622 write(output_unit, *)
623 write(output_unit, *)
624 end if
625
626 write(output_unit, '(a)') "Pulling from remote..."
627 write(output_unit, *)
628
629 ! Execute git pull
630 call execute_command_line("cd '" // trim(dir) // "' && git pull 2>&1", exitstat=stat, wait=.true.)
631
632 ! Show result
633 write(output_unit, *)
634 if (stat == 0) then
635 write(output_unit, '(a)') GREEN // "✓ Pull completed!" // RESET
636 else
637 write(output_unit, '(a)') RED // "✗ Pull failed" // RESET
638 end if
639 write(output_unit, '(a)') "Press any key to continue..."
640
641 ! Wait for keypress
642 read(*, '(a1)', advance='no') key
643
644 ! Invalidate git cache after pull
645 call invalidate_git_cache()
646 end subroutine git_pull_prompt
647
648 subroutine mark_incoming_changes(dir, files, count, has_incoming)
649 character(len=*), intent(in) :: dir
650 character(len=*), dimension(*), intent(in) :: files
651 integer, intent(in) :: count
652 logical, dimension(*), intent(out) :: has_incoming
653 character(len=MAX_PATH) :: temp_file, line, incoming_path
654 integer :: unit, ios, stat, i
655
656 ! Initialize all to false
657 do i = 1, count
658 has_incoming(i) = .false.
659 end do
660
661 ! Check if there's an upstream branch configured
662 ! Don't prompt - this is called automatically during refresh
663 call execute_command_line("cd '" // trim(dir) // "' && git rev-parse --abbrev-ref @{upstream} " // &
664 "> /dev/null 2>&1", exitstat=stat, wait=.true.)
665 if (stat /= 0) then
666 ! No upstream configured - silently return
667 return
668 end if
669
670 ! Get list of files that differ between HEAD and upstream
671 call get_environment_variable("HOME", temp_file)
672 temp_file = trim(temp_file) // "/.fortress_incoming"
673 call execute_command_line("cd '" // trim(dir) // "' && " // &
674 "git diff --name-only HEAD...@{upstream} > " // trim(temp_file) // " 2>/dev/null", &
675 exitstat=stat, wait=.true.)
676
677 if (stat /= 0) then
678 ! If diff fails, no incoming changes
679 call execute_command_line("rm -f " // trim(temp_file) // " 2>/dev/null")
680 return
681 end if
682
683 open(newunit=unit, file=temp_file, status='old', iostat=ios)
684 if (ios /= 0) then
685 call execute_command_line("rm -f " // trim(temp_file) // " 2>/dev/null")
686 return
687 end if
688
689 do
690 read(unit, '(a)', iostat=ios) line
691 if (ios /= 0) exit
692
693 if (len_trim(line) > 0) then
694 incoming_path = trim(line)
695 ! Mark this file as having incoming changes
696 do i = 1, count
697 if (trim(files(i)) == trim(incoming_path)) then
698 has_incoming(i) = .true.
699 exit
700 end if
701 end do
702 end if
703 end do
704
705 close(unit)
706 call execute_command_line("rm -f " // trim(temp_file) // " 2>/dev/null")
707 end subroutine mark_incoming_changes
708
709 end module git_ops
710