Fortran · 29187 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 ! Write indicators without RESET (caller handles that)
243 if (staged) then
244 if (highlighted) then
245 write(output_unit, '(a)', advance='no') GREEN // " ↑"
246 else
247 write(output_unit, '(a)', advance='no') GREEN // " ↑" // RESET
248 end if
249 end if
250 if (unstaged) then
251 if (highlighted) then
252 write(output_unit, '(a)', advance='no') RED // " ✗"
253 else
254 write(output_unit, '(a)', advance='no') RED // " ✗" // RESET
255 end if
256 end if
257 if (untracked) then
258 if (highlighted) then
259 write(output_unit, '(a)', advance='no') GREY // " ✗"
260 else
261 write(output_unit, '(a)', advance='no') GREY // " ✗" // RESET
262 end if
263 end if
264 if (has_incoming) then
265 if (highlighted) then
266 write(output_unit, '(a)', advance='no') YELLOW // " ↓"
267 else
268 write(output_unit, '(a)', advance='no') YELLOW // " ↓" // RESET
269 end if
270 end if
271 end subroutine write_git_indicators
272
273 subroutine git_add_file(dir, filename)
274 character(len=*), intent(in) :: dir, filename
275 character(len=MAX_PATH*2) :: git_cmd
276 integer :: stat
277
278 ! Build git add command
279 git_cmd = "cd '" // trim(dir) // "' && git add '" // trim(filename) // "' 2>/dev/null"
280 call execute_command_line(trim(git_cmd), exitstat=stat, wait=.true.)
281
282 ! Invalidate cache after git operation
283 call invalidate_git_cache()
284 end subroutine git_add_file
285
286 subroutine git_unstage_file(dir, filename)
287 character(len=*), intent(in) :: dir, filename
288 character(len=MAX_PATH*2) :: git_cmd
289 integer :: stat
290
291 ! Build git restore --staged command
292 git_cmd = "cd '" // trim(dir) // "' && git restore --staged '" // trim(filename) // "' 2>/dev/null"
293 call execute_command_line(trim(git_cmd), exitstat=stat, wait=.true.)
294
295 ! Invalidate cache after git operation
296 call invalidate_git_cache()
297 end subroutine git_unstage_file
298
299 subroutine git_commit_prompt(dir, repo_name)
300 character(len=*), intent(in) :: dir, repo_name
301 character(len=512) :: commit_msg
302 character(len=MAX_PATH*2) :: git_cmd
303 character(len=1) :: key
304 integer :: stat, ios
305
306 ! Clear screen and show prompt
307 write(output_unit, '(a)', advance='no') CLEAR
308 write(output_unit, '(a)', advance='no') BOLD // "Git Commit" // RESET // " - " // trim(repo_name)
309 write(output_unit, *)
310 write(output_unit, *)
311 write(output_unit, '(a)', advance='no') "Commit message: "
312
313 ! Restore terminal to canonical mode for reading input
314 call execute_command_line("stty icanon echo 2>/dev/null")
315
316 ! Read commit message
317 read(*, '(a)', iostat=ios) commit_msg
318
319 ! Restore raw mode
320 call execute_command_line("stty -icanon -echo min 1 time 0 2>/dev/null", wait=.true.)
321
322 if (ios == 0 .and. len_trim(commit_msg) > 0) then
323 ! Execute git commit (use single quotes for message to avoid escaping issues)
324 git_cmd = "cd '" // trim(dir) // "' && git commit -m '" // trim(commit_msg) // "' 2>&1"
325 call execute_command_line(trim(git_cmd), exitstat=stat, wait=.true.)
326
327 ! Show result briefly
328 write(output_unit, *)
329 if (stat == 0) then
330 write(output_unit, '(a)') GREEN // "✓ Committed successfully!" // RESET
331 else
332 write(output_unit, '(a)') RED // "✗ Commit failed (nothing to commit?)" // RESET
333 end if
334 write(output_unit, '(a)') "Press any key to continue..."
335
336 ! Wait for keypress
337 read(*, '(a1)', advance='no') key
338 end if
339 end subroutine git_commit_prompt
340
341 subroutine prompt_upstream_selection(dir, success)
342 character(len=*), intent(in) :: dir
343 logical, intent(out) :: success
344 character(len=MAX_PATH) :: temp_file, selected_branch
345 integer :: stat, unit, ios
346
347 success = .false.
348
349 ! Clear screen and show prompt
350 write(output_unit, '(a)', advance='no') CLEAR
351 write(output_unit, '(a)') BOLD // "No upstream branch configured" // RESET
352 write(output_unit, *)
353 write(output_unit, '(a)') "Select a remote branch to track:"
354 write(output_unit, *)
355
356 ! Restore terminal for fzf
357 call execute_command_line("stty sane 2>/dev/null")
358
359 ! Use fzf to select remote branch
360 call get_environment_variable("HOME", temp_file)
361 temp_file = trim(temp_file) // "/.fortress_upstream"
362 call execute_command_line("cd '" // trim(dir) // "' && git branch -r | grep -v HEAD | " // &
363 "sed 's/^[[:space:]]*//' | fzf --height=10 --prompt='Select upstream: ' > " // &
364 trim(temp_file) // " 2>/dev/null", exitstat=stat, wait=.true.)
365
366 if (stat /= 0) then
367 write(output_unit, '(a)') RED // "No upstream selected." // RESET
368 call execute_command_line("sleep 1")
369 ! Re-enable raw mode
370 call execute_command_line("stty -icanon -echo min 1 time 0 2>/dev/null", wait=.true.)
371 call execute_command_line("rm -f " // trim(temp_file) // " 2>/dev/null")
372 return
373 end if
374
375 ! Read selected branch
376 open(newunit=unit, file=temp_file, status='old', action='read', iostat=ios)
377 if (ios == 0) then
378 read(unit, '(a)', iostat=ios) selected_branch
379 close(unit)
380
381 if (ios == 0 .and. len_trim(selected_branch) > 0) then
382 ! Set upstream
383 call execute_command_line("cd '" // trim(dir) // "' && git branch --set-upstream-to=" // &
384 trim(selected_branch) // " 2>&1", exitstat=stat, wait=.true.)
385
386 if (stat == 0) then
387 write(output_unit, '(a)') GREEN // "✓ Upstream set to: " // trim(selected_branch) // RESET
388 success = .true.
389 else
390 write(output_unit, '(a)') RED // "✗ Failed to set upstream" // RESET
391 end if
392 call execute_command_line("sleep 1")
393 end if
394 end if
395
396 call execute_command_line("rm -f " // trim(temp_file) // " 2>/dev/null")
397 ! Re-enable raw mode
398 call execute_command_line("stty -icanon -echo min 1 time 0 2>/dev/null", wait=.true.)
399 end subroutine prompt_upstream_selection
400
401 subroutine git_push_prompt(dir, repo_name)
402 character(len=*), intent(in) :: dir, repo_name
403 character(len=MAX_PATH*2) :: git_cmd
404 character(len=1) :: key
405 integer :: stat
406 logical :: upstream_set
407
408 ! Clear screen and show prompt
409 write(output_unit, '(a)', advance='no') CLEAR
410 write(output_unit, '(a)', advance='no') BOLD // "Git Push" // RESET // " - " // trim(repo_name)
411 write(output_unit, *)
412 write(output_unit, *)
413
414 ! Check if upstream is configured
415 call execute_command_line("cd '" // trim(dir) // "' && git rev-parse --abbrev-ref @{upstream} " // &
416 "> /dev/null 2>&1", exitstat=stat, wait=.true.)
417
418 if (stat /= 0) then
419 ! No upstream configured - prompt user to select one
420 call prompt_upstream_selection(dir, upstream_set)
421 if (.not. upstream_set) return
422 ! Clear screen again after upstream selection
423 write(output_unit, '(a)', advance='no') CLEAR
424 write(output_unit, '(a)', advance='no') BOLD // "Git Push" // RESET // " - " // trim(repo_name)
425 write(output_unit, *)
426 write(output_unit, *)
427 end if
428
429 write(output_unit, '(a)') "Pushing to remote..."
430 write(output_unit, *)
431
432 ! Execute git push
433 git_cmd = "cd '" // trim(dir) // "' && git push 2>&1"
434 call execute_command_line(trim(git_cmd), exitstat=stat, wait=.true.)
435
436 ! Show result
437 write(output_unit, *)
438 if (stat == 0) then
439 write(output_unit, '(a)') GREEN // "✓ Pushed successfully!" // RESET
440 else
441 write(output_unit, '(a)') RED // "✗ Push failed" // RESET
442 end if
443 write(output_unit, '(a)') "Press any key to continue..."
444
445 ! Wait for keypress
446 read(*, '(a1)', advance='no') key
447 end subroutine git_push_prompt
448
449 subroutine git_tag_prompt(dir, repo_name)
450 character(len=*), intent(in) :: dir, repo_name
451 character(len=512) :: tag_name, tag_message
452 character(len=MAX_PATH*2) :: git_cmd
453 character(len=1) :: key
454 integer :: stat, ios
455
456 ! Clear screen and show prompt
457 write(output_unit, '(a)', advance='no') CLEAR
458 write(output_unit, '(a)', advance='no') BOLD // "Git Tag" // RESET // " - " // trim(repo_name)
459 write(output_unit, *)
460 write(output_unit, *)
461 write(output_unit, '(a)', advance='no') "Tag name: "
462
463 ! Restore terminal to canonical mode for reading input
464 call execute_command_line("stty icanon echo 2>/dev/null")
465
466 ! Read tag name
467 read(*, '(a)', iostat=ios) tag_name
468
469 if (ios == 0 .and. len_trim(tag_name) > 0) then
470 ! Read tag message (optional)
471 write(output_unit, '(a)', advance='no') "Tag message (enter for none): "
472 read(*, '(a)', iostat=ios) tag_message
473
474 ! Restore raw mode
475 call execute_command_line("stty -icanon -echo min 1 time 0 2>/dev/null", wait=.true.)
476
477 if (ios == 0) then
478 ! Execute git tag
479 if (len_trim(tag_message) > 0) then
480 ! Create annotated tag with message
481 git_cmd = "cd '" // trim(dir) // "' && git tag -a '" // trim(tag_name) // &
482 "' -m '" // trim(tag_message) // "' 2>&1"
483 else
484 ! Create lightweight tag (no message)
485 git_cmd = "cd '" // trim(dir) // "' && git tag '" // trim(tag_name) // "' 2>&1"
486 end if
487
488 call execute_command_line(trim(git_cmd), exitstat=stat, wait=.true.)
489
490 ! Show result
491 write(output_unit, *)
492 if (stat == 0) then
493 write(output_unit, '(a)') GREEN // "✓ Tag created: " // trim(tag_name) // RESET
494 else
495 write(output_unit, '(a)') RED // "✗ Failed to create tag" // RESET
496 end if
497 write(output_unit, '(a)') "Press any key to continue..."
498
499 ! Wait for keypress
500 read(*, '(a1)', advance='no') key
501 end if
502 else
503 ! Restore raw mode if tag name was empty
504 call execute_command_line("stty -icanon -echo min 1 time 0 2>/dev/null", wait=.true.)
505 end if
506 end subroutine git_tag_prompt
507
508 subroutine show_git_diff_fullscreen(dir, filename, is_staged, is_unstaged)
509 character(len=*), intent(in) :: dir, filename
510 logical, intent(in) :: is_staged, is_unstaged
511 character(len=MAX_PATH*2) :: git_cmd
512 character(len=1) :: key
513 integer :: ios
514
515 ! Clear screen
516 write(output_unit, '(a)', advance='no') CLEAR
517 write(output_unit, '(a)') BOLD // "Git Diff" // RESET // " - " // trim(filename)
518 write(output_unit, *)
519
520 ! Restore terminal to normal mode for better output
521 call execute_command_line("stty sane 2>/dev/null")
522
523 ! Build and execute diff command (show both staged and unstaged if both exist)
524 if (is_unstaged) then
525 write(output_unit, '(a)') BOLD // "Unstaged changes:" // RESET
526 git_cmd = "cd '" // trim(dir) // "' && git diff --color=always '" // trim(filename) // "' 2>&1"
527 call execute_command_line(trim(git_cmd), wait=.true.)
528 write(output_unit, *)
529 end if
530
531 if (is_staged) then
532 write(output_unit, '(a)') BOLD // "Staged changes:" // RESET
533 git_cmd = "cd '" // trim(dir) // "' && git diff --cached --color=always '" // trim(filename) // "' 2>&1"
534 call execute_command_line(trim(git_cmd), wait=.true.)
535 write(output_unit, *)
536 end if
537
538 ! Wait for keypress
539 write(output_unit, *)
540 write(output_unit, '(a)') GREY // "Press any key to return..." // RESET
541
542 ! Restore raw mode and give terminal time to settle
543 call execute_command_line("stty -icanon -echo min 1 time 0 2>/dev/null && sleep 0.05", wait=.true.)
544
545 ! Read keypress with error handling for any buffering issues
546 read(*, '(a1)', advance='no', iostat=ios) key
547 end subroutine show_git_diff_fullscreen
548
549 subroutine git_fetch_prompt(dir, repo_name)
550 character(len=*), intent(in) :: dir, repo_name
551 character(len=1) :: key
552 integer :: stat
553 logical :: upstream_set
554
555 ! Clear screen and show prompt
556 write(output_unit, '(a)', advance='no') CLEAR
557 write(output_unit, '(a)', advance='no') BOLD // "Git Fetch" // RESET // " - " // trim(repo_name)
558 write(output_unit, *)
559 write(output_unit, *)
560
561 ! Check if upstream is configured
562 call execute_command_line("cd '" // trim(dir) // "' && git rev-parse --abbrev-ref @{upstream} " // &
563 "> /dev/null 2>&1", exitstat=stat, wait=.true.)
564
565 if (stat /= 0) then
566 ! No upstream configured - prompt user to select one
567 call prompt_upstream_selection(dir, upstream_set)
568 if (.not. upstream_set) return
569 ! Clear screen again after upstream selection
570 write(output_unit, '(a)', advance='no') CLEAR
571 write(output_unit, '(a)', advance='no') BOLD // "Git Fetch" // RESET // " - " // trim(repo_name)
572 write(output_unit, *)
573 write(output_unit, *)
574 end if
575
576 write(output_unit, '(a)') "Fetching from remote..."
577 write(output_unit, *)
578
579 ! Execute git fetch
580 call execute_command_line("cd '" // trim(dir) // "' && git fetch 2>&1", exitstat=stat, wait=.true.)
581
582 ! Show result
583 write(output_unit, *)
584 if (stat == 0) then
585 write(output_unit, '(a)') GREEN // "✓ Fetch completed!" // RESET
586 else
587 write(output_unit, '(a)') RED // "✗ Fetch failed" // RESET
588 end if
589 write(output_unit, '(a)') "Press any key to continue..."
590
591 ! Wait for keypress
592 read(*, '(a1)', advance='no') key
593
594 ! Invalidate git cache after fetch
595 call invalidate_git_cache()
596 end subroutine git_fetch_prompt
597
598 subroutine git_pull_prompt(dir, repo_name)
599 character(len=*), intent(in) :: dir, repo_name
600 character(len=1) :: key
601 integer :: stat
602 logical :: upstream_set
603
604 ! Clear screen and show prompt
605 write(output_unit, '(a)', advance='no') CLEAR
606 write(output_unit, '(a)', advance='no') BOLD // "Git Pull" // RESET // " - " // trim(repo_name)
607 write(output_unit, *)
608 write(output_unit, *)
609
610 ! Check if upstream is configured
611 call execute_command_line("cd '" // trim(dir) // "' && git rev-parse --abbrev-ref @{upstream} " // &
612 "> /dev/null 2>&1", exitstat=stat, wait=.true.)
613
614 if (stat /= 0) then
615 ! No upstream configured - prompt user to select one
616 call prompt_upstream_selection(dir, upstream_set)
617 if (.not. upstream_set) return
618 ! Clear screen again after upstream selection
619 write(output_unit, '(a)', advance='no') CLEAR
620 write(output_unit, '(a)', advance='no') BOLD // "Git Pull" // RESET // " - " // trim(repo_name)
621 write(output_unit, *)
622 write(output_unit, *)
623 end if
624
625 write(output_unit, '(a)') "Pulling from remote..."
626 write(output_unit, *)
627
628 ! Execute git pull
629 call execute_command_line("cd '" // trim(dir) // "' && git pull 2>&1", exitstat=stat, wait=.true.)
630
631 ! Show result
632 write(output_unit, *)
633 if (stat == 0) then
634 write(output_unit, '(a)') GREEN // "✓ Pull completed!" // RESET
635 else
636 write(output_unit, '(a)') RED // "✗ Pull failed" // RESET
637 end if
638 write(output_unit, '(a)') "Press any key to continue..."
639
640 ! Wait for keypress
641 read(*, '(a1)', advance='no') key
642
643 ! Invalidate git cache after pull
644 call invalidate_git_cache()
645 end subroutine git_pull_prompt
646
647 subroutine mark_incoming_changes(dir, files, count, has_incoming)
648 character(len=*), intent(in) :: dir
649 character(len=*), dimension(*), intent(in) :: files
650 integer, intent(in) :: count
651 logical, dimension(*), intent(out) :: has_incoming
652 character(len=MAX_PATH) :: temp_file, line, incoming_path
653 integer :: unit, ios, stat, i
654
655 ! Initialize all to false
656 do i = 1, count
657 has_incoming(i) = .false.
658 end do
659
660 ! Check if there's an upstream branch configured
661 ! Don't prompt - this is called automatically during refresh
662 call execute_command_line("cd '" // trim(dir) // "' && git rev-parse --abbrev-ref @{upstream} " // &
663 "> /dev/null 2>&1", exitstat=stat, wait=.true.)
664 if (stat /= 0) then
665 ! No upstream configured - silently return
666 return
667 end if
668
669 ! Get list of files that differ between HEAD and upstream
670 call get_environment_variable("HOME", temp_file)
671 temp_file = trim(temp_file) // "/.fortress_incoming"
672 call execute_command_line("cd '" // trim(dir) // "' && " // &
673 "git diff --name-only HEAD...@{upstream} > " // trim(temp_file) // " 2>/dev/null", &
674 exitstat=stat, wait=.true.)
675
676 if (stat /= 0) then
677 ! If diff fails, no incoming changes
678 call execute_command_line("rm -f " // trim(temp_file) // " 2>/dev/null")
679 return
680 end if
681
682 open(newunit=unit, file=temp_file, status='old', iostat=ios)
683 if (ios /= 0) then
684 call execute_command_line("rm -f " // trim(temp_file) // " 2>/dev/null")
685 return
686 end if
687
688 do
689 read(unit, '(a)', iostat=ios) line
690 if (ios /= 0) exit
691
692 if (len_trim(line) > 0) then
693 incoming_path = trim(line)
694 ! Mark this file as having incoming changes
695 do i = 1, count
696 if (trim(files(i)) == trim(incoming_path)) then
697 has_incoming(i) = .true.
698 exit
699 end if
700 end do
701 end if
702 end do
703
704 close(unit)
705 call execute_command_line("rm -f " // trim(temp_file) // " 2>/dev/null")
706 end subroutine mark_incoming_changes
707
708 end module git_ops
709