返回 DeepSeek-Reasonix
workspaceid.go
根目录 / internal / workspaceid / workspaceid.go
1 // Package workspaceid derives the durable key a workspace's personal settings
2 // are filed under. Linked worktrees of one repository share a key, so a switch
3 // flipped in the main tree still applies in every worktree cut from it — nine
4 // worktrees are one project, not nine. Folders outside a repository key by
5 // path instead. Like environment.WorkspaceVCS this reads the filesystem rather
6 // than asking git, so it costs nothing on a settings render and cannot flap on
7 // a slow subprocess.
8 package workspaceid
9
10 import (
11 "crypto/sha256"
12 "encoding/hex"
13 "os"
14 "path/filepath"
15 "strings"
16 )
17
18 // RepoPrefix and PathPrefix tag which question a key answers, so a stored
19 // override says how far it reaches without consulting the filesystem again.
20 const (
21 RepoPrefix = "repo:"
22 PathPrefix = "path:"
23 )
24
25 // Info describes what a workspace root belongs to. Everything here comes from
26 // reading files, so a settings render can ask for it without spawning git.
27 type Info struct {
28 Key string // storage key: RepoPrefix or PathPrefix
29 // RepoDir is the repository's shared Git directory, identical across linked
30 // worktrees, or "" outside a repository. GitDir is this tree's own.
31 RepoDir string
32 GitDir string
33 Branch string // checked-out branch, or "" when detached or unreadable
34 // Trees counts the working trees sharing RepoDir, the main one included. It
35 // is 1 outside a repository.
36 Trees int
37 }
38
39 // Describe resolves root's repository identity in one walk.
40 func Describe(root string) Info {
41 base := canonical(root)
42 info := Info{Trees: 1}
43 if base == "" {
44 return info
45 }
46 for dir := base; ; {
47 if common, own, ok := gitDirsAt(filepath.Join(dir, ".git")); ok {
48 info.RepoDir, info.GitDir = common, own
49 info.Key = RepoPrefix + digest(common)
50 info.Branch = branchAt(own)
51 info.Trees = countTrees(common)
52 return info
53 }
54 parent := filepath.Dir(dir)
55 if parent == dir {
56 // No repository above root, so the folder itself is the identity —
57 // digest base, not the drive the walk ended on.
58 info.Key = PathPrefix + digest(base)
59 return info
60 }
61 dir = parent
62 }
63 }
64
65 // Key returns the storage key for root: RepoPrefix when root sits inside a Git
66 // repository, PathPrefix otherwise. An empty root yields an empty key.
67 func Key(root string) string { return Describe(root).Key }
68
69 // RepoDir returns the repository's shared Git directory for root — the same
70 // path for every linked worktree — or "" when root is not in a repository.
71 func RepoDir(root string) string { return Describe(root).RepoDir }
72
73 // countTrees counts the main tree plus every linked worktree registration.
74 func countTrees(commonDir string) int {
75 entries, err := os.ReadDir(filepath.Join(commonDir, "worktrees"))
76 if err != nil {
77 return 1
78 }
79 trees := 1
80 for _, entry := range entries {
81 if entry.IsDir() {
82 trees++
83 }
84 }
85 return trees
86 }
87
88 func branchAt(gitDir string) string {
89 body, err := os.ReadFile(filepath.Join(gitDir, "HEAD"))
90 if err != nil {
91 return ""
92 }
93 head := strings.TrimSpace(string(body))
94 ref, ok := strings.CutPrefix(head, "ref:")
95 if !ok {
96 return ""
97 }
98 return strings.TrimPrefix(strings.TrimSpace(ref), "refs/heads/")
99 }
100
101 // gitDirsAt resolves one .git marker into the shared git dir and this tree's
102 // own. A directory is the repository itself; a file points at a real git dir,
103 // which for a linked worktree lives under <common>/worktrees/<name> — cutting
104 // there is what makes worktrees converge, and a submodule has no such element.
105 // Both sides canonicalize, or /var/… and /private/var/… read as two repositories.
106 func gitDirsAt(marker string) (common, own string, ok bool) {
107 info, err := os.Lstat(marker)
108 if err != nil {
109 return "", "", false
110 }
111 if info.IsDir() {
112 dir := canonical(marker)
113 return dir, dir, true
114 }
115 if !info.Mode().IsRegular() {
116 return "", "", false
117 }
118 body, err := os.ReadFile(marker)
119 if err != nil {
120 return "", "", false
121 }
122 target := ""
123 for line := range strings.SplitSeq(string(body), "\n") {
124 if rest, cut := strings.CutPrefix(strings.TrimSpace(line), "gitdir:"); cut {
125 target = strings.TrimSpace(rest)
126 break
127 }
128 }
129 if target == "" {
130 return "", "", false
131 }
132 if !filepath.IsAbs(target) {
133 target = filepath.Join(filepath.Dir(marker), target)
134 }
135 own = canonical(target)
136 return canonical(cutWorktrees(own)), own, true
137 }
138
139 func cutWorktrees(gitDir string) string {
140 parts := strings.Split(filepath.ToSlash(gitDir), "/")
141 for i := len(parts) - 1; i > 0; i-- {
142 if parts[i] == "worktrees" {
143 return filepath.FromSlash(strings.Join(parts[:i], "/"))
144 }
145 }
146 return gitDir
147 }
148
149 func canonical(path string) string {
150 path = strings.TrimSpace(path)
151 if path == "" {
152 return ""
153 }
154 if abs, err := filepath.Abs(path); err == nil {
155 path = abs
156 }
157 if real, err := filepath.EvalSymlinks(path); err == nil {
158 path = real
159 }
160 return filepath.Clean(path)
161 }
162
163 // digest keeps the same width worktree.Create already uses for repository keys.
164 // canonical resolved symlinks first, which on Windows also settles the casing.
165 func digest(value string) string {
166 sum := sha256.Sum256([]byte(filepath.ToSlash(value)))
167 return hex.EncodeToString(sum[:8])
168 }
169
169 lines GO