返回 DeepSeek-Reasonix
workspace_changes.go
根目录 / desktop / workspace_changes.go
1 package main
2
3 import (
4 "bytes"
5 "context"
6 "fmt"
7 "io"
8 "os"
9 "os/exec"
10 "path/filepath"
11 "sort"
12 "strings"
13 "sync"
14 "time"
15
16 "reasonix/internal/control"
17 "reasonix/internal/diff"
18 "reasonix/internal/gitcmd"
19 )
20
21 type gitStatusEntry struct {
22 Path string
23 OldPath string
24 Status string
25 }
26
27 type workspaceChangeAccumulator struct {
28 view WorkspaceChangeView
29 hasSession bool
30 hasGit bool
31 }
32
33 const (
34 workspaceGitBranchCacheTTL = 2 * time.Second
35 // Bound both decoded file contents and rendered patches before they cross
36 // the desktop bridge; generated files must not turn a preview click into OOM.
37 workspaceChangeDetailLimit = 2 * 1024 * 1024
38 )
39
40 type workspaceGitBranchCacheEntry struct {
41 branch string
42 expires time.Time
43 refreshing bool
44 request *int
45 }
46
47 var workspaceGitBranchCache = struct {
48 sync.Mutex
49 entries map[string]workspaceGitBranchCacheEntry
50 }{entries: map[string]workspaceGitBranchCacheEntry{}}
51
52 var workspaceGitBranchForMetaProbe = workspaceGitBranch
53
54 func (a *App) WorkspaceChanges(tabID string) WorkspaceChangesView {
55 ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
56 defer cancel()
57 return a.workspaceChanges(ctx, tabID)
58 }
59
60 func (a *App) workspaceChanges(ctx context.Context, tabID string) WorkspaceChangesView {
61 out := WorkspaceChangesView{Files: []WorkspaceChangeView{}, GitAvailable: true}
62 tabID = strings.TrimSpace(tabID)
63
64 workspaceRoot, ctrl, ok := a.workspaceChangesTarget(tabID)
65 if !ok {
66 out.GitAvailable = false
67 out.GitErr = fmt.Sprintf("tab %q not found", tabID)
68 return out
69 }
70
71 base, err := workspaceBaseFromRoot(workspaceRoot)
72 if err != nil {
73 out.GitAvailable = false
74 out.GitErr = err.Error()
75 return out
76 }
77
78 out.GitBranch, _ = workspaceGitBranchContext(ctx, base)
79
80 changes := map[string]*workspaceChangeAccumulator{}
81 add := func(path string) *workspaceChangeAccumulator {
82 path = normalizeWorkspaceRelPath(base, path)
83 if path == "" {
84 return nil
85 }
86 if changes[path] == nil {
87 changes[path] = &workspaceChangeAccumulator{view: WorkspaceChangeView{Path: path}}
88 }
89 return changes[path]
90 }
91
92 if ctrl != nil {
93 for _, meta := range ctrl.Checkpoints() {
94 for _, path := range meta.Paths {
95 acc := add(path)
96 if acc == nil {
97 continue
98 }
99 acc.hasSession = true
100 if len(acc.view.Turns) == 0 || acc.view.Turns[len(acc.view.Turns)-1] != meta.Turn {
101 acc.view.Turns = append(acc.view.Turns, meta.Turn)
102 }
103 if meta.Time.UnixMilli() >= acc.view.LatestTime {
104 acc.view.LatestPrompt = meta.Prompt
105 acc.view.LatestTime = meta.Time.UnixMilli()
106 }
107 }
108 }
109 }
110
111 gitEntries, gitErr := workspaceGitStatusContext(ctx, base)
112 if gitErr != nil {
113 out.GitAvailable = false
114 out.GitErr = gitErr.Error()
115 out.Incomplete = true
116 }
117 var untracked []string
118 for _, entry := range gitEntries {
119 acc := add(entry.Path)
120 if acc == nil {
121 continue
122 }
123 acc.hasGit = true
124 acc.view.GitStatus = entry.Status
125 acc.view.OldPath = normalizeWorkspaceRelPath(base, entry.OldPath)
126 if entry.Status == "??" {
127 untracked = append(untracked, entry.Path)
128 }
129 }
130
131 out.Files = make([]WorkspaceChangeView, 0, len(changes))
132 for _, acc := range changes {
133 if acc.hasSession {
134 acc.view.Sources = append(acc.view.Sources, "session")
135 // Session-owned files with a recorded preimage can one-click revert
136 // to the first Reasonix touch (not Git HEAD).
137 if ctrl != nil {
138 if state, ok := ctrl.CheckpointFileState(acc.view.Path); ok && state.Owned {
139 acc.view.CanSessionRevert = true
140 }
141 }
142 }
143 if acc.hasGit {
144 acc.view.Sources = append(acc.view.Sources, "git")
145 }
146 out.Files = append(out.Files, acc.view)
147 }
148 sort.Slice(out.Files, func(i, j int) bool {
149 a, b := out.Files[i], out.Files[j]
150 if len(a.Sources) != len(b.Sources) {
151 return len(a.Sources) > len(b.Sources)
152 }
153 return strings.ToLower(a.Path) < strings.ToLower(b.Path)
154 })
155 if out.GitAvailable {
156 out.Added, out.Removed, out.Incomplete = workspaceGitDiffTally(ctx, base, untracked)
157 }
158 return out
159 }
160
161 func (a *App) workspaceChangesTarget(tabID string) (string, control.SessionAPI, bool) {
162 a.mu.RLock()
163 defer a.mu.RUnlock()
164 var tab *WorkspaceTab
165 if tabID == "" {
166 tab = a.activeTabLocked()
167 } else {
168 tab = a.tabs[tabID]
169 }
170 if tab == nil {
171 return "", nil, tabID == ""
172 }
173 return tab.WorkspaceRoot, tab.Ctrl, true
174 }
175
176 func (a *App) workspaceBaseForTab(tabID string) (string, error) {
177 tabID = strings.TrimSpace(tabID)
178 workspaceRoot, _, ok := a.workspaceChangesTarget(tabID)
179 if !ok {
180 return "", fmt.Errorf("tab %q not found", tabID)
181 }
182 return workspaceBaseFromRoot(workspaceRoot)
183 }
184
185 // WorkspaceChangeDetail returns the current patch for one file in the
186 // requested tab. Git is authoritative when available because HEAD -> worktree
187 // includes both staged and unstaged edits. Session checkpoints provide a
188 // git-free fallback and cover files edited by Reasonix before Git notices them.
189 func (a *App) WorkspaceChangeDetail(tabID, path string) (WorkspaceChangeDetailView, error) {
190 workspaceRoot, ctrl, ok := a.workspaceChangesTarget(strings.TrimSpace(tabID))
191 if !ok {
192 return WorkspaceChangeDetailView{}, fmt.Errorf("tab %q not found", tabID)
193 }
194 base, err := workspaceBaseFromRoot(workspaceRoot)
195 if err != nil {
196 return WorkspaceChangeDetailView{}, err
197 }
198 rel := normalizeWorkspaceRelPath(base, path)
199 if rel == "" {
200 return WorkspaceChangeDetailView{}, os.ErrInvalid
201 }
202 if _, ok, err := workspacePathForBase(base, filepath.FromSlash(rel)); err != nil || !ok {
203 if err != nil {
204 return WorkspaceChangeDetailView{}, err
205 }
206 return WorkspaceChangeDetailView{}, os.ErrInvalid
207 }
208
209 if detail, found := workspaceGitChangeDetail(base, rel); found {
210 return detail, nil
211 }
212 if ctrl != nil {
213 if state, found := ctrl.CheckpointFileState(rel); found {
214 return workspaceCheckpointChangeDetail(base, rel, state.Content)
215 }
216 }
217 return WorkspaceChangeDetailView{}, nil
218 }
219
220 func workspaceGitChangeDetail(base, rel string) (WorkspaceChangeDetailView, bool) {
221 entries, err := workspaceGitStatus(base)
222 if err != nil {
223 return WorkspaceChangeDetailView{}, false
224 }
225 var entry *gitStatusEntry
226 for i := range entries {
227 if entries[i].Path == rel {
228 entry = &entries[i]
229 break
230 }
231 }
232 if entry == nil {
233 return WorkspaceChangeDetailView{}, false
234 }
235
236 // Untracked files are omitted by git diff. In an unborn repository HEAD is
237 // absent as well, so synthesize the same create/delete patch from disk.
238 if entry.Status == "??" || !workspaceGitHasHead(base) {
239 detail, err := workspaceCheckpointChangeDetail(base, rel, nil)
240 if err != nil {
241 return WorkspaceChangeDetailView{}, false
242 }
243 detail.Source = "git"
244 return detail, true
245 }
246
247 args := []string{"-C", base, "diff", "--no-ext-diff", "--no-textconv", "--relative", "HEAD", "--", filepath.FromSlash(rel)}
248 if entry.OldPath != "" && entry.OldPath != rel {
249 args = append(args, filepath.FromSlash(entry.OldPath))
250 }
251 raw, truncated, err := workspaceGitDiffOutput(args...)
252 if err != nil {
253 return WorkspaceChangeDetailView{}, false
254 }
255 if truncated {
256 return WorkspaceChangeDetailView{Source: "git", Truncated: true}, true
257 }
258 patch := strings.TrimSpace(string(raw))
259 if patch == "" {
260 return WorkspaceChangeDetailView{}, false
261 }
262 added, removed := tallyUnifiedPatch(patch)
263 binary := strings.Contains(patch, "Binary files ") || strings.Contains(patch, "GIT binary patch")
264 return WorkspaceChangeDetailView{Diff: &patch, Source: "git", Added: added, Removed: removed, Binary: binary}, true
265 }
266
267 func workspaceGitHasHead(base string) bool {
268 return workspaceGit("-C", base, "rev-parse", "--verify", "HEAD").Run() == nil
269 }
270
271 func workspaceGitDiffOutput(args ...string) ([]byte, bool, error) {
272 cmd := workspaceGit(args...)
273 stdout, err := cmd.StdoutPipe()
274 if err != nil {
275 return nil, false, err
276 }
277 cmd.Stderr = io.Discard
278 if err := cmd.Start(); err != nil {
279 _ = stdout.Close()
280 return nil, false, err
281 }
282 raw, readErr := io.ReadAll(io.LimitReader(stdout, workspaceChangeDetailLimit+1))
283 if readErr != nil {
284 _ = stdout.Close()
285 if cmd.Process != nil {
286 _ = cmd.Process.Kill()
287 }
288 _ = cmd.Wait()
289 return nil, false, readErr
290 }
291 if len(raw) > workspaceChangeDetailLimit {
292 _ = stdout.Close()
293 if cmd.Process != nil {
294 _ = cmd.Process.Kill()
295 }
296 _ = cmd.Wait()
297 return nil, true, nil
298 }
299 waitErr := cmd.Wait()
300 if waitErr != nil {
301 return nil, false, waitErr
302 }
303 return raw, false, nil
304 }
305
306 func workspaceCheckpointChangeDetail(base, rel string, old *string) (WorkspaceChangeDetailView, error) {
307 path, ok, err := workspacePathForBase(base, filepath.FromSlash(rel))
308 if err != nil || !ok {
309 return WorkspaceChangeDetailView{}, err
310 }
311 oldText := ""
312 if old != nil {
313 if len(*old) > workspaceChangeDetailLimit {
314 return WorkspaceChangeDetailView{Source: "session", Truncated: true}, nil
315 }
316 oldText = *old
317 }
318 newText, exists, truncated, err := workspaceCurrentText(path)
319 if err != nil {
320 return WorkspaceChangeDetailView{}, err
321 }
322 if truncated {
323 return WorkspaceChangeDetailView{Source: "session", Truncated: true}, nil
324 }
325 kind := diff.Modify
326 if old == nil {
327 kind = diff.Create
328 } else if !exists {
329 kind = diff.Delete
330 }
331 change := diff.Build(rel, oldText, newText, kind)
332 if len(change.Diff) > workspaceChangeDetailLimit {
333 return WorkspaceChangeDetailView{Source: "session", Truncated: true}, nil
334 }
335 if change.Diff == "" && !change.Binary {
336 return WorkspaceChangeDetailView{Source: "session"}, nil
337 }
338 patch := change.Diff
339 return WorkspaceChangeDetailView{
340 Diff: &patch,
341 Source: "session",
342 Added: change.Added,
343 Removed: change.Removed,
344 Binary: change.Binary,
345 }, nil
346 }
347
348 func workspaceCurrentText(path string) (string, bool, bool, error) {
349 info, err := os.Lstat(path)
350 if os.IsNotExist(err) {
351 return "", false, false, nil
352 }
353 if err != nil {
354 return "", false, false, err
355 }
356 if info.Mode()&os.ModeSymlink != 0 {
357 target, err := os.Readlink(path)
358 return target, true, false, err
359 }
360 if !info.Mode().IsRegular() {
361 return "", true, false, fmt.Errorf("workspace change path %q is not a regular file", path)
362 }
363 raw, truncated, err := readFileUTF8Limit(path, workspaceChangeDetailLimit)
364 return string(raw), true, truncated, err
365 }
366
367 func tallyUnifiedPatch(patch string) (added, removed int) {
368 inHunk := false
369 for line := range strings.SplitSeq(patch, "\n") {
370 switch {
371 case strings.HasPrefix(line, "@@"):
372 inHunk = true
373 case strings.HasPrefix(line, "diff --git "):
374 inHunk = false
375 case inHunk && strings.HasPrefix(line, "+"):
376 added++
377 case inHunk && strings.HasPrefix(line, "-"):
378 removed++
379 }
380 }
381 return added, removed
382 }
383
384 // workspaceGit builds a console-hidden git probe. gitcmd supplies the shared
385 // invocation baseline: CREATE_NO_WINDOW so git's own children inherit the
386 // invisible console, and the config overrides that keep a probe from spawning a
387 // background daemon that opens a console of its own (#3906).
388 func workspaceGit(args ...string) *exec.Cmd {
389 return workspaceGitCommand(context.Background(), args...)
390 }
391
392 func workspaceGitCommand(ctx context.Context, args ...string) *exec.Cmd {
393 return gitcmd.Command(ctx, "", args...)
394 }
395
396 func workspaceGitOutputWithTimeout(timeout time.Duration, args ...string) ([]byte, error) {
397 ctx, cancel := context.WithTimeout(context.Background(), timeout)
398 defer cancel()
399 return workspaceGitCommand(ctx, args...).Output()
400 }
401
402 func workspaceGitStatus(base string) ([]gitStatusEntry, error) {
403 ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
404 defer cancel()
405 return workspaceGitStatusContext(ctx, base)
406 }
407
408 func workspaceGitStatusContext(ctx context.Context, base string) ([]gitStatusEntry, error) {
409 // Git's porcelain paths are repository-relative even when -C points at a
410 // subdirectory. Derive the textual repository prefix from Git itself instead
411 // of comparing absolute paths: Windows may spell the same directory once as
412 // an 8.3 path and once as a long path, which makes filepath.Rel reject every
413 // otherwise valid status entry.
414 prefixCmd := workspaceGitCommand(ctx, "-C", base, "rev-parse", "--show-prefix")
415 prefixRaw, err := prefixCmd.Output()
416 if err != nil {
417 return nil, err
418 }
419 prefix := strings.TrimSpace(string(prefixRaw))
420 cmd := workspaceGitCommand(ctx, "-C", base, "status", "--porcelain=v1", "-z", "--untracked-files=all", "--", ".")
421 raw, err := cmd.Output()
422 if err != nil {
423 return nil, err
424 }
425 entries := parseGitStatusPorcelainZ(raw)
426 out := make([]gitStatusEntry, 0, len(entries))
427 for _, entry := range entries {
428 entry.Path = workspaceRelPathFromGitPrefix(base, prefix, entry.Path)
429 if entry.Path == "" {
430 continue
431 }
432 entry.OldPath = workspaceRelPathFromGitPrefix(base, prefix, entry.OldPath)
433 out = append(out, entry)
434 }
435 return out, nil
436 }
437
438 func parseGitStatusPorcelainZ(raw []byte) []gitStatusEntry {
439 parts := bytes.Split(raw, []byte{0})
440 out := make([]gitStatusEntry, 0, len(parts))
441 for i := 0; i < len(parts); i++ {
442 part := parts[i]
443 if len(part) < 4 {
444 continue
445 }
446 status := string(part[:2])
447 path := string(part[3:])
448 entry := gitStatusEntry{Path: path, Status: strings.TrimSpace(status)}
449 if strings.ContainsAny(status, "RC") && i+1 < len(parts) {
450 i++
451 entry.OldPath = string(parts[i])
452 }
453 out = append(out, entry)
454 }
455 return out
456 }
457
458 func normalizeWorkspaceRelPath(base, path string) string {
459 path = strings.TrimSpace(path)
460 if path == "" {
461 return ""
462 }
463 if filepath.IsAbs(path) {
464 if rel, err := filepath.Rel(base, path); err == nil {
465 path = rel
466 }
467 }
468 path = filepath.Clean(path)
469 if path == "." || path == ".." || strings.HasPrefix(path, ".."+string(filepath.Separator)) {
470 return ""
471 }
472 return filepath.ToSlash(path)
473 }
474
475 func workspaceRelPathFromGitPrefix(base, prefix, path string) string {
476 path = filepath.ToSlash(strings.TrimSpace(path))
477 prefix = filepath.ToSlash(strings.TrimSpace(prefix))
478 if path == "" {
479 return ""
480 }
481 if prefix != "" {
482 if !strings.HasPrefix(path, prefix) {
483 return ""
484 }
485 path = strings.TrimPrefix(path, prefix)
486 }
487 return normalizeWorkspaceRelPath(base, filepath.FromSlash(path))
488 }
489
490 // workspaceGitBranchForMeta is the cached variant used by high-frequency UI
491 // metadata refreshes. It never waits for git on the caller path: stale branch
492 // metadata is less harmful than blocking tab activation or hydration. Workflows
493 // that need an immediate git read, such as WorkspaceChanges, should call
494 // workspaceGitBranch directly.
495 func workspaceGitBranchForMeta(base string) string {
496 key := filepath.Clean(base)
497 now := time.Now()
498
499 workspaceGitBranchCache.Lock()
500 if cached, ok := workspaceGitBranchCache.entries[key]; ok {
501 branch := cached.branch
502 if now.Before(cached.expires) || cached.refreshing {
503 workspaceGitBranchCache.Unlock()
504 return branch
505 }
506 cached.refreshing = true
507 cached.request = new(int)
508 workspaceGitBranchCache.entries[key] = cached
509 workspaceGitBranchCache.Unlock()
510 go refreshWorkspaceGitBranchForMeta(key, base, cached.request)
511 return branch
512 }
513
514 request := new(int)
515 workspaceGitBranchCache.entries[key] = workspaceGitBranchCacheEntry{
516 expires: now.Add(workspaceGitBranchCacheTTL),
517 refreshing: true,
518 request: request,
519 }
520 workspaceGitBranchCache.Unlock()
521
522 go refreshWorkspaceGitBranchForMeta(key, base, request)
523 return ""
524 }
525
526 func refreshWorkspaceGitBranchForMeta(key, base string, request *int) {
527 branch := ""
528 // Store via defer so the refreshing flag is always cleared, even when the
529 // probe panics or exits the goroutine early; otherwise the entry would stay
530 // marked refreshing forever and never update again.
531 defer func() {
532 storeNow := time.Now()
533 workspaceGitBranchCache.Lock()
534 defer workspaceGitBranchCache.Unlock()
535 if cached, ok := workspaceGitBranchCache.entries[key]; !ok || cached.request != request {
536 return
537 }
538 if len(workspaceGitBranchCache.entries) > 256 {
539 for k, cached := range workspaceGitBranchCache.entries {
540 if storeNow.After(cached.expires) {
541 delete(workspaceGitBranchCache.entries, k)
542 }
543 }
544 }
545 workspaceGitBranchCache.entries[key] = workspaceGitBranchCacheEntry{branch: branch, expires: storeNow.Add(workspaceGitBranchCacheTTL)}
546 }()
547
548 branch = workspaceGitBranchForMetaProbe(base)
549 }
550
551 // workspaceGitBranch returns the current git branch name for the repo rooted
552 // at base, or an empty string when base is not inside a git repository or when
553 // git is unavailable.
554 func workspaceGitBranch(base string) string {
555 ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
556 defer cancel()
557 branch, _ := workspaceGitBranchContext(ctx, base)
558 return branch
559 }
560
561 func workspaceGitBranchContext(ctx context.Context, base string) (string, error) {
562 raw, err := workspaceGitCommand(ctx, "-C", base, "branch", "--show-current").Output()
563 if err != nil {
564 return "", err
565 }
566 if branch := strings.TrimSpace(string(raw)); branch != "" {
567 return branch, nil
568 }
569
570 raw, err = workspaceGitCommand(ctx, "-C", base, "rev-parse", "--short", "HEAD").Output()
571 if err != nil {
572 return "", err
573 }
574 short := strings.TrimSpace(string(raw))
575 if short == "" {
576 return "", nil
577 }
578 return "@" + short, nil
579 }
580
581 // GitBranches returns all local git branches for the active workspace's repo.
582 func (a *App) GitBranches() ([]string, error) {
583 base, err := a.activeWorkspaceBase()
584 if err != nil {
585 return nil, err
586 }
587 return workspaceLocalBranches(base)
588 }
589
590 func (a *App) GitCheckout(branch string) error {
591 base, err := a.activeWorkspaceBase()
592 if err != nil {
593 return err
594 }
595 return workspaceCheckoutBranch(base, branch, false)
596 }
597
598 const gitRefInvalidChars = " ~^:?*[\\" + "\t\n"
599
600 func validGitBranchName(name string) bool {
601 name = strings.TrimSpace(name)
602 if name == "" || strings.HasPrefix(name, "-") || strings.Contains(name, "..") ||
603 strings.HasSuffix(name, "/") || strings.HasSuffix(name, ".") || strings.Contains(name, "@{") {
604 return false
605 }
606 return !strings.ContainsAny(name, gitRefInvalidChars)
607 }
608
609 func (a *App) GitCreateBranch(name string) error {
610 base, err := a.activeWorkspaceBase()
611 if err != nil {
612 return err
613 }
614 return workspaceCheckoutBranch(base, name, true)
615 }
616
617 type GitCommitView struct {
618 Hash string `json:"hash"`
619 Author string `json:"author"`
620 Date string `json:"date"`
621 Message string `json:"message"`
622 }
623
624 type GitCommitDetailView struct {
625 Diff *string `json:"diff,omitempty"`
626 Files []string `json:"files,omitempty"`
627 }
628
629 func (a *App) WorkspaceGitHistory(tabID string, path string) ([]GitCommitView, error) {
630 base, err := a.workspaceBaseForTab(tabID)
631 if err != nil {
632 return nil, err
633 }
634
635 args := []string{"-C", base, "log", "--pretty=format:%H%x00%an%x00%ad%x00%s", "-z", "-n", "100"}
636 if path != "" {
637 args = append(args, "--", path)
638 }
639
640 cmd := workspaceGit(args...)
641 raw, err := cmd.Output()
642 if err != nil {
643 return nil, err
644 }
645
646 parts := bytes.Split(raw, []byte{0})
647 out := []GitCommitView{}
648 // 4 parts per commit: hash, author, date, message
649 for i := 0; i+3 < len(parts); i += 4 {
650 out = append(out, GitCommitView{
651 Hash: string(parts[i]),
652 Author: string(parts[i+1]),
653 Date: string(parts[i+2]),
654 Message: string(parts[i+3]),
655 })
656 }
657 return out, nil
658 }
659
660 func (a *App) WorkspaceGitCommitDetail(tabID string, hash string, path string) (GitCommitDetailView, error) {
661 base, err := a.workspaceBaseForTab(tabID)
662 if err != nil {
663 return GitCommitDetailView{}, err
664 }
665
666 if path != "" {
667 // Single file diff
668 cmd := workspaceGit("-C", base, "show", "--relative", "--pretty=format:", "--patch", hash, "--", path)
669 raw, err := cmd.Output()
670 if err != nil {
671 return GitCommitDetailView{}, err
672 }
673 diffStr := strings.TrimSpace(string(raw))
674 return GitCommitDetailView{Diff: &diffStr}, nil
675 }
676
677 // Project level: list of files changed
678 cmd := workspaceGit("-C", base, "diff-tree", "--relative", "--no-commit-id", "--name-only", "-r", hash)
679 raw, err := cmd.Output()
680 if err != nil {
681 return GitCommitDetailView{}, err
682 }
683
684 lines := strings.Split(strings.TrimSpace(string(raw)), "\n")
685 var files []string
686 for _, line := range lines {
687 if line != "" {
688 files = append(files, line)
689 }
690 }
691 return GitCommitDetailView{Files: files}, nil
692 }
693
693 lines GO