1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
package main

import (
	"bufio"
	"bytes"
	"context"
	"fmt"
	"io"
	"log"
	"os"
	"os/exec"
	"path/filepath"
	"regexp"
	"sort"
	"strconv"
	"strings"
	"sync/atomic"
	"time"

	tea "github.com/charmbracelet/bubbletea"
)

// ============================================================================
// Message Types - Results returned by git operations
// ============================================================================

// BlameUpdateMsg is sent by git blame --incremental goroutine with batches of blame data.
// Sent periodically (every 50 lines) to update the UI incrementally as blame data arrives.
type BlameUpdateMsg struct {
	Updates []struct {
		LineNumber int       // Line number in file (1-based)
		CommitHash string    // Full commit SHA
		Author     string    // Author name
		AuthorTime time.Time // Commit timestamp
	}
}

// BlameCompleteMsg signals that git blame has finished processing all lines successfully.
type BlameCompleteMsg struct{}

// BlameErrorMsg indicates git blame encountered an error (malformed output, command failure, etc.).
type BlameErrorMsg struct {
	Err error // Error from git blame command or parsing
}

// BlameUntrackedMsg signals that the file is untracked (not in HEAD) so all lines should be marked as uncommitted.
type BlameUntrackedMsg struct{}

// FileLoadErrorMsg indicates file loading failed (file not found, not a git repo, etc.).
type FileLoadErrorMsg struct {
	Err error // Error from file reading or git repository detection
}

// DirectoryLoadedMsg signals directory files have been loaded from git log.
type DirectoryLoadedMsg struct {
	FileEntries []FileEntry // Files with their last modification metadata
	Ctx         context.Context
	RepoRoot    string // Git repository root for running git commands
}

// extendedMsgResult contains the result of checking whether a commit has an extended message body.
// Sent by background git log tasks to update commitExtendedInfo cache.
type extendedMsgResult struct {
	commitHash  string // Full commit SHA that was checked
	subject     string // Commit subject line (first line of commit message)
	hasExtended bool   // Whether commit has body text beyond subject and git attributes
}

// ============================================================================
// Git Repository Operations
// ============================================================================

// getGitRepoRoot finds the git repository root for a given file or directory path.
// Uses "git rev-parse --show-toplevel" to find the repository root directory.
func getGitRepoRoot(filePath string) (string, error) {
	// Get directory containing the file (or the directory itself if filePath is a directory)
	absPath, err := filepath.Abs(filePath)
	if err != nil {
		return "", fmt.Errorf("failed to get absolute path: %w", err)
	}

	// Check if path is a directory
	dir := absPath
	fileInfo, err := os.Stat(absPath)
	if err == nil && !fileInfo.IsDir() {
		// It's a file, use its parent directory
		dir = filepath.Dir(absPath)
	}
	// If stat failed or it's a directory, use absPath as-is

	cmd := exec.Command("git", "-C", dir, "rev-parse", "--show-toplevel")
	output, err := cmd.Output()
	if err != nil {
		return "", fmt.Errorf("not a git repository: %w", err)
	}

	return strings.TrimSpace(string(output)), nil
}

// getLatestCommitForFile returns the hash of the most recent commit that modified the file
func getLatestCommitForFile(repoRoot, filename string) (string, error) {
	// Convert filename to absolute path first
	absPath, err := filepath.Abs(filename)
	if err != nil {
		return "", fmt.Errorf("failed to get absolute path: %w", err)
	}

	relPath, err := filepath.Rel(repoRoot, absPath)
	if err != nil {
		return "", fmt.Errorf("failed to get relative path: %w", err)
	}

	cmd := exec.Command("git", "-C", repoRoot, "log", "-1", "--format=%H", "--", relPath)
	output, err := cmd.Output()
	if err != nil {
		return "", fmt.Errorf("git log failed: %w", err)
	}
	return strings.TrimSpace(string(output)), nil
}

// getRelativePathFromRepoRoot converts an absolute file path to a path relative to the git repository root.
// Used for passing file paths to git commands which expect repo-relative paths.
func getRelativePathFromRepoRoot(filePath, repoRoot string) (string, error) {
	absPath, err := filepath.Abs(filePath)
	if err != nil {
		return "", fmt.Errorf("failed to get absolute path: %w", err)
	}

	relPath, err := filepath.Rel(repoRoot, absPath)
	if err != nil {
		return "", fmt.Errorf("failed to get relative path: %w", err)
	}
	return relPath, nil
}

// getCommitDetails fetches full commit details and syntax highlights them.
// Uses "git show --no-patch" to get commit metadata and message, then colorizes with bat.
func getCommitDetails(repoRoot, commitHash string) (ColorizedText, error) {
	// Get commit details from git
	gitCmd := exec.Command("git", "-C", repoRoot, "show", "--no-patch", "--pretty=format:%H%n%an <%ae>%n%ad%n%n%B", commitHash)
	gitOutput, err := gitCmd.Output()
	if err != nil {
		return ColorizedText{}, err
	}

	// Try to colorize with bat using gitlog language
	colorized, err := ColorizeTextWithBat(string(gitOutput), "gitlog")
	if err != nil {
		// If bat fails, return plain git output
		return NewColorizedText(string(gitOutput)), nil
	}

	return colorized, nil
}

// ============================================================================
// Git Blame Operations
// ============================================================================

// startIncrementalBlame starts git blame --incremental and returns a channel of messages.
// Runs git blame in a goroutine, parsing output and sending BlameUpdateMsg batches.
// Sends BlameCompleteMsg when done or BlameErrorMsg on error.
func startIncrementalBlame(ctx context.Context, filename string) chan tea.Msg {
	msgChan := make(chan tea.Msg, 100)

	go func() {
		defer close(msgChan)

		// Get git repository root
		repoRoot, err := getGitRepoRoot(filename)
		if err != nil {
			msgChan <- BlameErrorMsg{Err: fmt.Errorf("failed to find git repository: %w", err)}
			return
		}
		log.Printf("Git repo root: %s", repoRoot)

		// Get relative path from repo root
		relPath, err := getRelativePathFromRepoRoot(filename, repoRoot)
		if err != nil {
			msgChan <- BlameErrorMsg{Err: fmt.Errorf("failed to get relative path: %w", err)}
			return
		}
		log.Printf("Relative path: %s", relPath)

		// Run git blame --incremental from the repository root
		cmdArgs := []string{"-C", repoRoot, "blame", "--incremental", relPath}
		log.Printf("Running: git %s", strings.Join(cmdArgs, " "))
		cmd := exec.CommandContext(ctx, "git", cmdArgs...)

		stdout, err := cmd.StdoutPipe()
		if err != nil {
			msgChan <- BlameErrorMsg{Err: fmt.Errorf("failed to create stdout pipe: %w", err)}
			return
		}

		// Capture stderr to a buffer
		var stderrBuf bytes.Buffer
		cmd.Stderr = &stderrBuf

		if err := cmd.Start(); err != nil {
			msgChan <- BlameErrorMsg{Err: fmt.Errorf("failed to start git blame: %w", err)}
			return
		}

		// Parse incremental output and send updates
		// Only send complete message if command completed successfully (not cancelled)
		if parseIncrementalBlame(ctx, stdout, &stderrBuf, cmd, msgChan) {
			msgChan <- BlameCompleteMsg{}
		}
	}()

	return msgChan
}

// parseIncrementalBlame parses git blame --incremental output and sends messages.
// Batches updates (50 lines at a time OR 250ms elapsed) for UI responsiveness. Caches commit metadata
// to avoid redundant parsing. Returns true if completed successfully, false if cancelled.
func parseIncrementalBlame(ctx context.Context, stdout io.ReadCloser, stderr *bytes.Buffer, cmd *exec.Cmd, msgChan chan tea.Msg) bool {
	scanner := bufio.NewScanner(stdout)
	commitCache := make(map[string]struct {
		Author     string
		AuthorTime time.Time
	})

	batch := BlameUpdateMsg{}
	lastSendTime := time.Now() // Track when we last sent a batch for time-based flushing

	for scanner.Scan() {
		line := scanner.Text()

		// Parse blame entry start: <sha> <source-line> <result-line> <num-lines>
		fields := strings.Fields(line)
		if len(fields) >= 4 && len(fields[0]) == 40 {
			commitHash := fields[0]
			resultLine, err := strconv.Atoi(fields[2])
			if err != nil {
				continue
			}
			numLines, err := strconv.Atoi(fields[3])
			if err != nil {
				continue
			}

			var author string
			var authorTime time.Time

			// Check if we've seen this commit before
			if cached, ok := commitCache[commitHash]; ok {
				author = cached.Author
				authorTime = cached.AuthorTime
			} else {
				// New commit, read metadata
				for scanner.Scan() {
					metaLine := scanner.Text()
					if after, ok0 := strings.CutPrefix(metaLine, "author "); ok0 {
						author = after
					} else if after, ok0 := strings.CutPrefix(metaLine, "author-time "); ok0 {
						timeStr := after
						timestamp, err := strconv.ParseInt(timeStr, 10, 64)
						if err == nil {
							authorTime = time.Unix(timestamp, 0)
						}
					} else if strings.HasPrefix(metaLine, "filename ") {
						// End of entry
						break
					}
				}

				// Cache this commit
				commitCache[commitHash] = struct {
					Author     string
					AuthorTime time.Time
				}{author, authorTime}
			}

			// Add updates for all lines in this group to batch
			for i := range numLines {
				batch.Updates = append(batch.Updates, struct {
					LineNumber int
					CommitHash string
					Author     string
					AuthorTime time.Time
				}{
					LineNumber: resultLine + i,
					CommitHash: commitHash,
					Author:     author,
					AuthorTime: authorTime,
				})
			}

			// Send batch every 50 lines or 250ms for responsiveness
			if len(batch.Updates) > 0 && (len(batch.Updates) >= 50 || time.Since(lastSendTime) >= 250*time.Millisecond) {
				msgChan <- batch
				batch = BlameUpdateMsg{}
				lastSendTime = time.Now()
			}
		}
	}

	// Send remaining updates
	if len(batch.Updates) > 0 {
		msgChan <- batch
	}

	// Check if command completed successfully
	if err := cmd.Wait(); err != nil {
		// Check if this was due to context cancellation (expected) or an actual error
		if ctx.Err() == nil {
			// Actual error - get stderr message
			stderrMsg := stderr.String()

			// Check if this is an untracked file error (file not in HEAD)
			isUntrackedFile := strings.Contains(stderrMsg, "no such path") && strings.Contains(stderrMsg, "in HEAD")

			if isUntrackedFile {
				// Send special message to mark all lines as untracked/uncommitted
				log.Printf("File is untracked (not in HEAD), marking all lines as uncommitted")
				msgChan <- BlameUntrackedMsg{}
			} else {
				// Send error message to UI for other types of errors
				if stderrMsg != "" {
					msgChan <- BlameErrorMsg{Err: fmt.Errorf("git blame failed: %s", stderrMsg)}
				} else {
					msgChan <- BlameErrorMsg{Err: fmt.Errorf("git blame failed: %w", err)}
				}
			}
		}
		// If context was cancelled, it's expected - don't send error
		return false
	}
	return true
}

// ============================================================================
// Git Directory Operations
// ============================================================================

// startDirectoryLoad loads directory files asynchronously using git log.
// Streams git log output and parses file entries until we have enough or hit limits.
// Returns DirectoryLoadedMsg on success or FileLoadErrorMsg on failure.
func startDirectoryLoad(ctx context.Context, dirpath string, maxFiles int) tea.Cmd {
	return func() tea.Msg {
		// Check if context is already cancelled
		select {
		case <-ctx.Done():
			return nil
		default:
		}

		// Get git repository root
		repoRoot, err := getGitRepoRoot(dirpath)
		if err != nil {
			return FileLoadErrorMsg{Err: fmt.Errorf("failed to find git repository: %w", err)}
		}

		// Get relative path from repo root
		relPath, err := getRelativePathFromRepoRoot(dirpath, repoRoot)
		if err != nil {
			return FileLoadErrorMsg{Err: fmt.Errorf("failed to get relative path: %w", err)}
		}

		// Run git log with machine-readable format
		// Format: %at (timestamp) %H (full hash) %an (author name)
		// --name-status: Shows M/A/D status and filename
		cmdArgs := []string{"-C", repoRoot, "log", "--format=%at %H %an", "--name-status", "-n", "1000", "--", relPath}
		log.Printf("Running: git %s", strings.Join(cmdArgs, " "))
		cmd := exec.CommandContext(ctx, "git", cmdArgs...)

		stdout, err := cmd.StdoutPipe()
		if err != nil {
			return FileLoadErrorMsg{Err: fmt.Errorf("failed to create stdout pipe: %w", err)}
		}

		if err := cmd.Start(); err != nil {
			return FileLoadErrorMsg{Err: fmt.Errorf("failed to start git log: %w", err)}
		}

		// Parse streaming output
		fileEntries := parseGitLogForFiles(ctx, stdout, maxFiles, 10000)

		// Wait for command to finish (or context cancellation)
		if err := cmd.Wait(); err != nil {
			if ctx.Err() == nil {
				// Not cancelled, actual error
				log.Printf("git log error: %v", err)
			}
		}

		return DirectoryLoadedMsg{
			FileEntries: fileEntries,
			Ctx:         ctx,
			RepoRoot:    repoRoot,
		}
	}
}

// parseGitLogForFiles parses git log output and extracts file entries.
// Stops when: uniqueFiles >= maxFiles OR linesRead > maxLines OR EOF.
// Returns deduplicated file entries sorted by timestamp (most recent first).
func parseGitLogForFiles(ctx context.Context, stdout io.ReadCloser, maxFiles int, maxLines int) []FileEntry {
	scanner := bufio.NewScanner(stdout)
	fileMap := make(map[string]FileEntry) // Deduplicate by path

	var currentTimestamp time.Time
	var currentHash string
	var currentAuthor string
	linesRead := 0

	for scanner.Scan() {
		select {
		case <-ctx.Done():
			return fileEntriesToSlice(fileMap)
		default:
		}

		line := scanner.Text()
		linesRead++

		// Check line limit
		if linesRead > maxLines {
			log.Printf("Reached max lines (%d), stopping git log parse", maxLines)
			break
		}

		// Empty line separates commits
		if line == "" {
			continue
		}

		// Check if this is a format line (timestamp hash author)
		// Format lines have exactly 3 fields when split by space (assuming author has no spaces, or we take everything after hash as author)
		fields := strings.Fields(line)
		if len(fields) >= 3 {
			// Try to parse as timestamp
			timestamp, err := strconv.ParseInt(fields[0], 10, 64)
			if err == nil && len(fields[1]) == 40 {
				// This is a format line
				currentTimestamp = time.Unix(timestamp, 0)
				currentHash = fields[1]
				currentAuthor = strings.Join(fields[2:], " ") // Author may have spaces
				continue
			}
		}

		// Check if this is a status line (M/A/D <tab> filename)
		if len(fields) >= 2 && len(fields[0]) == 1 {
			status := fields[0]
			// The filename is everything after the status and whitespace
			// Use tab as separator since git log --name-status uses tab
			parts := strings.SplitN(line, "\t", 2)
			if len(parts) != 2 {
				continue
			}
			filepath := parts[1]

			// Only add if we haven't seen this file yet (keep most recent)
			if _, exists := fileMap[filepath]; !exists {
				fileMap[filepath] = FileEntry{
					Path:       filepath,
					CommitHash: currentHash,
					Author:     currentAuthor,
					AuthorTime: currentTimestamp,
					Status:     status,
				}

				// Check if we have enough files
				if len(fileMap) >= maxFiles {
					log.Printf("Reached max files (%d), stopping git log parse", maxFiles)
					return fileEntriesToSlice(fileMap)
				}
			}
		}
	}

	return fileEntriesToSlice(fileMap)
}

// fileEntriesToSlice converts file map to sorted slice (most recent first).
func fileEntriesToSlice(fileMap map[string]FileEntry) []FileEntry {
	entries := make([]FileEntry, 0, len(fileMap))
	for _, entry := range fileMap {
		entries = append(entries, entry)
	}

	// Sort by timestamp (most recent first)
	sort.Slice(entries, func(i, j int) bool {
		return entries[i].AuthorTime.After(entries[j].AuthorTime)
	})

	return entries
}

// ============================================================================
// Git Commit Message Operations
// ============================================================================

// checkExtendedMsgCmd returns a Cmd that checks if a commit has an extended message and fetches the subject.
// Runs "git log" to get subject and body, filters out git attributes (Signed-off-by, etc).
// Increments/decrements runningTasks counter for concurrency throttling.
func checkExtendedMsgCmd(repoRoot, commitHash string, runningTasks *atomic.Int32) tea.Cmd {
	return func() tea.Msg {
		// Track task lifecycle
		runningTasks.Add(1)
		defer runningTasks.Add(-1)

		// Get commit subject and body in one command
		gitCmd := exec.Command("git", "-C", repoRoot, "log", "-1", "--format=%s%n---SUBJECT-BODY-SEP---%n%b", commitHash)
		gitOutput, err := gitCmd.Output()
		if err != nil {
			// If we can't check, assume no extended message
			return extendedMsgResult{commitHash: commitHash, subject: "", hasExtended: false}
		}

		// Parse subject and body
		parts := strings.SplitN(string(gitOutput), "\n---SUBJECT-BODY-SEP---\n", 2)
		subject := ""
		body := ""
		if len(parts) > 0 {
			subject = strings.TrimSpace(parts[0])
		}
		if len(parts) > 1 {
			body = strings.TrimSpace(parts[1])
		}

		// Check if body has extended message (non-attribute lines)
		// Filter out git attribute lines like "Signed-off-by:", "Co-Authored-By:", etc.
		hasExtended := false
		if len(body) > 0 {
			// Pattern matches lines that are git attributes: word chars/hyphens/underscores followed by colon
			attributePattern := regexp.MustCompile(`^[a-zA-Z0-9_-]+:\s*`)
			lines := strings.SplitSeq(body, "\n")
			for line := range lines {
				trimmed := strings.TrimSpace(line)
				// Skip empty lines
				if trimmed == "" {
					continue
				}
				// If line doesn't match attribute pattern, it's extended content
				if !attributePattern.MatchString(trimmed) {
					hasExtended = true
					break
				}
			}
		}

		return extendedMsgResult{commitHash: commitHash, subject: subject, hasExtended: hasExtended}
	}
}