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