From 6c5ab15980ab3d39660134073d724addae9c8579 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Almeida?= <57680069+zeval@users.noreply.github.com> Date: Tue, 15 Sep 2026 13:20:54 +0200 Subject: [PATCH] feat: identify symlinks in changes and files (#3675) --- .../server/process/workspace_git_status.go | 8 +- .../server/process/workspace_git_symlink.go | 89 ++++++++ .../process/workspace_git_symlink_test.go | 133 +++++++++++ .../server/process/workspace_git_untracked.go | 14 +- .../internal/agentctl/types/streams/git.go | 6 + .../codemirror/codemirror-code-editor.tsx | 52 ++++- .../editors/monaco/monaco-code-editor.tsx | 2 + .../editors/monaco/monaco-editor-toolbar.tsx | 8 + .../components/shared/symlink-indicator.tsx | 22 ++ .../task/changes-panel-file-row.tsx | 3 + .../task/changes-panel-helpers.test.ts | 4 + .../components/task/changes-panel-helpers.ts | 2 + .../components/task/file-binary-viewer.tsx | 15 +- .../task/file-browser-actions.test.ts | 2 + .../components/task/file-browser-actions.ts | 1 + .../components/task/file-browser-parts.tsx | 2 + .../components/task/file-editor-content.tsx | 3 + .../web/components/task/file-editor-panel.tsx | 16 +- .../web/components/task/file-image-viewer.tsx | 9 +- apps/web/components/task/file-tab-content.tsx | 3 + .../components/task/file-viewer-header.tsx | 16 +- .../components/task/html-preview-content.tsx | 7 + .../task/markdown-preview-content.tsx | 12 +- .../task/mobile/mobile-file-viewer-panel.tsx | 9 +- .../task/task-center-panel-file-tabs.test.ts | 24 ++ .../task/task-center-panel-file-tabs.ts | 37 ++- .../task-center-panel-restoration.test.ts | 2 + .../task/task-center-panel-restoration.ts | 1 + .../git/mobile-symlink-identification.spec.ts | 59 +++++ apps/web/e2e/tests/git/symlink-file.spec.ts | 118 +++++++--- .../domains/session/git-change-facets.test.ts | 13 ++ .../domains/session/git-change-facets.ts | 1 + apps/web/hooks/file-editors-sync.test.ts | 30 ++- apps/web/hooks/file-editors-sync.ts | 13 +- .../use-file-editors.build-state.test.ts | 2 + apps/web/hooks/use-file-editors.ts | 1 + apps/web/hooks/use-task-center-file-open.ts | 1 + .../session-runtime/git-status-state.ts | 3 + .../set-git-status-return.test.ts | 12 + .../lib/state/slices/session-runtime/types.ts | 2 + apps/web/lib/types/backend.ts | 2 + apps/web/lib/types/workspace-files.ts | 1 + apps/web/src/locales/en/editors.json | 3 +- apps/web/src/locales/pseudo/editors.json | 3 +- apps/web/src/locales/pt-pt/editors.json | 3 +- apps/web/src/locales/zh-cn/editors.json | 3 +- apps/web/src/locales/zh-hk/editors.json | 3 +- apps/web/src/locales/zh-tw/editors.json | 3 +- docs/plans/symlink-identification/plan.md | 140 ++++++++++++ .../task-01-identify-symlinks.md | 216 ++++++++++++++++++ docs/public/sessions-and-review.md | 7 + .../requirements/symlink-identification.md | 56 +++++ .../system-design/symlink-identification.md | 93 ++++++++ 53 files changed, 1225 insertions(+), 65 deletions(-) create mode 100644 apps/backend/internal/agentctl/server/process/workspace_git_symlink.go create mode 100644 apps/backend/internal/agentctl/server/process/workspace_git_symlink_test.go create mode 100644 apps/web/components/shared/symlink-indicator.tsx create mode 100644 apps/web/e2e/tests/git/mobile-symlink-identification.spec.ts create mode 100644 docs/plans/symlink-identification/plan.md create mode 100644 docs/plans/symlink-identification/task-01-identify-symlinks.md create mode 100644 docs/specs/workspaces/requirements/symlink-identification.md create mode 100644 docs/specs/workspaces/system-design/symlink-identification.md diff --git a/apps/backend/internal/agentctl/server/process/workspace_git_status.go b/apps/backend/internal/agentctl/server/process/workspace_git_status.go index e8dd97f3b5..258a9324b9 100644 --- a/apps/backend/internal/agentctl/server/process/workspace_git_status.go +++ b/apps/backend/internal/agentctl/server/process/workspace_git_status.go @@ -317,6 +317,8 @@ func (wt *WorkspaceTracker) computeGitStatus(ctx context.Context) (types.GitStat return update, err } + wt.enrichSymlinkMetadata(ctx, &update) + // Enrich file info with diff data (additions, deletions, and actual diff content) if err := wt.enrichWithDiffData(ctx, &update, prior); err != nil { return update, err @@ -930,12 +932,12 @@ func (wt *WorkspaceTracker) applyPorcelainLine(line string, update *types.GitSta fileInfo.Status = fileStatusDeleted fileInfo.Staged = true update.Deleted = append(update.Deleted, filePath) - case workTreeStatus == 'M': + case workTreeStatus == 'M' || workTreeStatus == 'T': // Modified in worktree - unstaged modification fileInfo.Status = fileStatusModified fileInfo.Staged = false update.Modified = append(update.Modified, filePath) - case indexStatus == 'M': + case indexStatus == 'M' || indexStatus == 'T': // Modified and staged (no worktree changes) fileInfo.Status = fileStatusModified fileInfo.Staged = true @@ -967,7 +969,7 @@ func (wt *WorkspaceTracker) applyPorcelainLine(line string, update *types.GitSta func porcelainChangeFacet(status byte, oldPath string) *types.FileChangeFacet { change := &types.FileChangeFacet{OldPath: oldPath} switch status { - case 'M': + case 'M', 'T': change.Status = fileStatusModified case 'A': change.Status = "added" diff --git a/apps/backend/internal/agentctl/server/process/workspace_git_symlink.go b/apps/backend/internal/agentctl/server/process/workspace_git_symlink.go new file mode 100644 index 0000000000..91048d2c7d --- /dev/null +++ b/apps/backend/internal/agentctl/server/process/workspace_git_symlink.go @@ -0,0 +1,89 @@ +package process + +import ( + "context" + "os" + "path/filepath" + "strings" + + "github.com/kandev/kandev/internal/agentctl/types" +) + +// enrichSymlinkMetadata observes entry modes independently of patch enrichment. +// Raw diffs retain modes even when patch content is binary or exceeds its budget. +func (wt *WorkspaceTracker) enrichSymlinkMetadata(ctx context.Context, update *types.GitStatusUpdate) { + if len(update.Files) == 0 { + return + } + staged := wt.readChangedSymlinkModes(ctx, true) + unstaged := wt.readChangedSymlinkModes(ctx, false) + for path, file := range update.Files { + if ctx.Err() != nil { + return + } + if file.StagedChange != nil { + file.StagedChange.IsSymlink = symlinkModeValue(staged, path) + } + if file.UnstagedChange != nil { + file.UnstagedChange.IsSymlink = symlinkModeValue(unstaged, path) + } + switch { + case file.Status == fileStatusUntracked: + if filepath.IsLocal(path) { + if info, err := os.Lstat(filepath.Join(wt.workDir, path)); err == nil { + value := info.Mode()&os.ModeSymlink != 0 + file.IsSymlink = &value + if file.UnstagedChange != nil { + file.UnstagedChange.IsSymlink = &value + } + } + } + case file.Staged: + file.IsSymlink = symlinkModeValue(staged, path) + default: + file.IsSymlink = symlinkModeValue(unstaged, path) + } + update.Files[path] = file + } +} + +func symlinkModeValue(modes map[string]bool, path string) *bool { + value, ok := modes[path] + if !ok { + return nil + } + return &value +} + +func (wt *WorkspaceTracker) readChangedSymlinkModes(ctx context.Context, staged bool) map[string]bool { + args := []string{"diff", "--raw", "-z", "--no-renames", "--no-ext-diff"} + if staged { + args = append(args, "--cached") + } + out, err := wt.runGitOutput(ctx, args...) + if err != nil { + return nil + } + return parseChangedSymlinkModes(string(out)) +} + +// --no-renames gives exactly one NUL-delimited path per raw diff record. +func parseChangedSymlinkModes(raw string) map[string]bool { + modes := make(map[string]bool) + records := strings.Split(raw, "\x00") + for i := 0; i+1 < len(records); i += 2 { + fields := strings.Fields(records[i]) + if len(fields) != 5 || !strings.HasPrefix(fields[0], ":") || records[i+1] == "" { + continue + } + mode := fields[1] + if fields[4] == "D" { + mode = strings.TrimPrefix(fields[0], ":") + } + if mode != "100644" && mode != "100755" && mode != "120000" && mode != "160000" { + continue + } + modes[records[i+1]] = mode == "120000" + } + return modes +} diff --git a/apps/backend/internal/agentctl/server/process/workspace_git_symlink_test.go b/apps/backend/internal/agentctl/server/process/workspace_git_symlink_test.go new file mode 100644 index 0000000000..4038af04bb --- /dev/null +++ b/apps/backend/internal/agentctl/server/process/workspace_git_symlink_test.go @@ -0,0 +1,133 @@ +package process + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/kandev/kandev/internal/agentctl/types" +) + +// @covers AC-WORKSPACES-SYMLINK-001.1, AC-WORKSPACES-SYMLINK-001.3 +func TestSymlinkStatusMetadata(t *testing.T) { + for _, scenario := range []string{"untracked", "staged", "unstaged", "deleted", "staged-deleted", "staged-delete-recreate", "renamed", "mixed-type"} { + t.Run(scenario, func(t *testing.T) { + repo, cleanup := setupTestRepo(t) + defer cleanup() + name := "link file.txt" + link := filepath.Join(repo, name) + if err := os.Symlink("missing-target", link); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + name = prepareSymlinkScenario(t, repo, name, scenario) + tracker := NewWorkspaceTracker(repo, newTestLogger(t)) + status, err := tracker.getGitStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + b, err := json.Marshal(status.Files[name]) + if err != nil { + t.Fatal(err) + } + var file struct { + IsSymlink *bool `json:"is_symlink"` + Staged *struct { + IsSymlink *bool `json:"is_symlink"` + } `json:"staged_change"` + Unstaged *struct { + IsSymlink *bool `json:"is_symlink"` + } `json:"unstaged_change"` + } + if err := json.Unmarshal(b, &file); err != nil { + t.Fatal(err) + } + want := scenario != "mixed-type" && scenario != "staged-delete-recreate" + if file.IsSymlink == nil || *file.IsSymlink != want { + t.Fatalf("symlink metadata = %s, want %v", b, want) + } + if (scenario == "mixed-type" || scenario == "staged-delete-recreate") && (file.Staged == nil || file.Unstaged == nil || file.Staged.IsSymlink == nil || !*file.Staged.IsSymlink || file.Unstaged.IsSymlink == nil || *file.Unstaged.IsSymlink) { + t.Fatalf("mixed layer metadata = %s", b) + } + }) + } +} + +func prepareSymlinkScenario(t *testing.T, repo, name, scenario string) string { + t.Helper() + link := filepath.Join(repo, name) + if scenario != "untracked" { + runGit(t, repo, "add", "--", name) + } + if scenario == "untracked" || scenario == "staged" { + return name + } + runGit(t, repo, "commit", "-m", "add symlink") + if err := os.Remove(link); err != nil { + t.Fatal(err) + } + switch scenario { + case "unstaged": + if err := os.Symlink("another-missing-target", link); err != nil { + t.Fatal(err) + } + case "staged-deleted": + runGit(t, repo, "add", "--", name) + case "staged-delete-recreate": + runGit(t, repo, "add", "--", name) + writeFile(t, repo, name, "regular now\n") + case "renamed": + name = "renamed link.txt" + if err := os.Symlink("missing-target", filepath.Join(repo, name)); err != nil { + t.Fatal(err) + } + runGit(t, repo, "add", "-A") + case "mixed-type": + if err := os.Symlink("new-target", link); err != nil { + t.Fatal(err) + } + runGit(t, repo, "add", "--", name) + if err := os.Remove(link); err != nil { + t.Fatal(err) + } + writeFile(t, repo, name, "regular now\n") + } + return name +} + +func TestSymlinkRawModes(t *testing.T) { + raw := ":100644 120000 abc def T\x00tab\tand\nnewline\x00:120000 000000 abc def D\x00deleted\x00:120000 100644 abc def T\x00regular\x00:100644 000000 abc def U\x00conflict\x00" + modes := parseChangedSymlinkModes(raw) + if len(modes) != 3 || !modes["tab\tand\nnewline"] || !modes["deleted"] || modes["regular"] { + t.Fatalf("modes = %#v", modes) + } + if symlinkModeValue(modes, "unknown") != nil { + t.Fatal("missing metadata must remain unknown") + } +} + +func TestSymlinkMetadataWithoutPatch(t *testing.T) { + repo, cleanup := setupTestRepo(t) + defer cleanup() + external := filepath.Join(t.TempDir(), "external.txt") + if err := os.WriteFile(external, []byte("private target"), 0600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(external, filepath.Join(repo, "external")); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + runGit(t, repo, "add", "external") + tracker := NewWorkspaceTracker(repo, newTestLogger(t)) + update := types.GitStatusUpdate{Files: map[string]types.FileInfo{"external": {Path: "external", Status: "added", Staged: true, DiffSkipReason: "budget_exceeded"}}} + tracker.enrichSymlinkMetadata(context.Background(), &update) + file := update.Files["external"] + if file.IsSymlink == nil || !*file.IsSymlink || file.Diff != "" || file.DiffSkipReason != "budget_exceeded" { + t.Fatalf("file = %+v", file) + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if modes := tracker.readChangedSymlinkModes(ctx, true); modes != nil { + t.Fatalf("cancelled metadata read = %#v", modes) + } +} diff --git a/apps/backend/internal/agentctl/server/process/workspace_git_untracked.go b/apps/backend/internal/agentctl/server/process/workspace_git_untracked.go index 83c7fb3b21..4494bf7aae 100644 --- a/apps/backend/internal/agentctl/server/process/workspace_git_untracked.go +++ b/apps/backend/internal/agentctl/server/process/workspace_git_untracked.go @@ -54,10 +54,22 @@ func (wt *WorkspaceTracker) applyUntrackedOutput( return err } update.Untracked = append(update.Untracked, filePath) - update.Files[filePath] = types.FileInfo{ + fileInfo := types.FileInfo{ Path: filePath, Status: fileStatusUntracked, } + if existing, ok := update.Files[filePath]; ok && existing.Staged { + stagedChange := existing.StagedChange + if stagedChange == nil { + stagedChange = &types.FileChangeFacet{ + Status: existing.Status, + OldPath: existing.OldPath, + } + } + fileInfo.StagedChange = stagedChange + fileInfo.UnstagedChange = &types.FileChangeFacet{Status: fileStatusUntracked} + } + update.Files[filePath] = fileInfo } return nil } diff --git a/apps/backend/internal/agentctl/types/streams/git.go b/apps/backend/internal/agentctl/types/streams/git.go index 212ff87b5c..21258d9aa5 100644 --- a/apps/backend/internal/agentctl/types/streams/git.go +++ b/apps/backend/internal/agentctl/types/streams/git.go @@ -101,6 +101,9 @@ type GitStatusUpdate struct { // one change between HEAD and the index plus another between the index and the // working tree. type FileChangeFacet struct { + // IsSymlink describes the destination entry, or the source for a deletion. + IsSymlink *bool `json:"is_symlink,omitempty"` + // Status indicates the layer status: "modified", "added", "deleted", or "renamed". Status string `json:"status"` @@ -122,6 +125,9 @@ type FileChangeFacet struct { // FileInfo represents detailed information about a file's git status. type FileInfo struct { + // IsSymlink describes the destination entry, or the source for a deletion. + IsSymlink *bool `json:"is_symlink,omitempty"` + // Path is the file path relative to workspace root. Path string `json:"path"` diff --git a/apps/web/components/editors/codemirror/codemirror-code-editor.tsx b/apps/web/components/editors/codemirror/codemirror-code-editor.tsx index 60851f5d14..b901190886 100644 --- a/apps/web/components/editors/codemirror/codemirror-code-editor.tsx +++ b/apps/web/components/editors/codemirror/codemirror-code-editor.tsx @@ -1,5 +1,7 @@ "use client"; +import { SymlinkIndicator } from "@/components/shared/symlink-indicator"; + import { useCallback, useEffect, useRef, useState } from "react"; import CodeMirror, { type ReactCodeMirrorRef } from "@uiw/react-codemirror"; import type { EditorView } from "@codemirror/view"; @@ -54,6 +56,7 @@ type FileEditorContentProps = { taskId?: string | null; repositoryId?: string | null; worktreePath?: string; + isSymlink?: boolean; repo?: string; enableComments?: boolean; previewKind?: FilePreviewKind; @@ -270,10 +273,39 @@ function CodeMirrorPreviewButton({ ); } +function CodeMirrorFileLabel({ + path, + worktreePath, + isSymlink, + isDirty, + diffStats, +}: { + path: string; + worktreePath?: string; + isSymlink?: boolean; + isDirty: boolean; + diffStats: { additions: number; deletions: number } | null; +}) { + return ( +
+ + {toRelativePath(path, worktreePath)} + + + {isDirty && diffStats && ( + + {formatDiffStats(diffStats.additions, diffStats.deletions)} + + )} +
+ ); +} + /** Toolbar for the CodeMirror code editor. */ function CodeMirrorToolbar({ path, worktreePath, + isSymlink, isDirty, isSaving, diffStats, @@ -297,6 +329,7 @@ function CodeMirrorToolbar({ }: { path: string; worktreePath?: string; + isSymlink?: boolean; isDirty: boolean; isSaving: boolean; diffStats: { additions: number; deletions: number } | null; @@ -322,16 +355,13 @@ function CodeMirrorToolbar({ return ( - - {toRelativePath(path, worktreePath)} - - {isDirty && diffStats && ( - - {formatDiffStats(diffStats.additions, diffStats.deletions)} - - )} - + } right={
@@ -471,6 +501,7 @@ export function CodeMirrorCodeEditor(props: FileEditorContentProps) { taskId, repositoryId, worktreePath, + isSymlink, repo, enableComments = false, previewKind, @@ -490,6 +521,7 @@ export function CodeMirrorCodeEditor(props: FileEditorContentProps) { {toRelativePath(path, worktreePath)} + {isDirty && diffStats && ( {formatDiffStats(diffStats.additions, diffStats.deletions)} @@ -284,6 +289,7 @@ interface MonacoEditorToolbarProps { path: string; repositoryName?: string; worktreePath?: string; + isSymlink?: boolean; isDirty: boolean; isSaving: boolean; diffStats: { additions: number; deletions: number } | null; @@ -315,6 +321,7 @@ export function MonacoEditorToolbar({ path, repositoryName, worktreePath, + isSymlink, isDirty, isSaving, diffStats, @@ -348,6 +355,7 @@ export function MonacoEditorToolbar({ diff --git a/apps/web/components/shared/symlink-indicator.tsx b/apps/web/components/shared/symlink-indicator.tsx new file mode 100644 index 0000000000..bc5125bf5e --- /dev/null +++ b/apps/web/components/shared/symlink-indicator.tsx @@ -0,0 +1,22 @@ +import { IconLink } from "@tabler/icons-react"; +import { useTranslation } from "react-i18next"; + +export function SymlinkIndicator({ + isSymlink, + showLabel = false, +}: { + isSymlink?: boolean; + showLabel?: boolean; +}) { + const { t } = useTranslation(); + if (!isSymlink) return null; + return ( + + + ); +} diff --git a/apps/web/components/task/changes-panel-file-row.tsx b/apps/web/components/task/changes-panel-file-row.tsx index 356ae85bf8..553ebe5408 100644 --- a/apps/web/components/task/changes-panel-file-row.tsx +++ b/apps/web/components/task/changes-panel-file-row.tsx @@ -1,5 +1,7 @@ "use client"; +import { SymlinkIndicator } from "@/components/shared/symlink-indicator"; + import { IconArrowBackUp, IconPlus, @@ -133,6 +135,7 @@ export function FileRow({ onUnstage={onUnstage} /> )} +