@dosu/cli 0.49.0-alpha.1 → 0.49.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +0 -3
- package/bin/dosu.js +8073 -42594
- package/package.json +3 -12
- package/bin/runtime/deja-darwin-arm64 +0 -0
- package/bin/runtime/deja-darwin-x64 +0 -0
- package/third_party/deja-vu/LICENSE +0 -21
- package/third_party/deja-vu/README.md +0 -30
- package/third_party/deja-vu/dosu-project-scope.patch +0 -847
|
@@ -1,847 +0,0 @@
|
|
|
1
|
-
From 73638f3f88691ee8bdb2051c4fbe0e9a5bcf4011 Mon Sep 17 00:00:00 2001
|
|
2
|
-
From: Caspian Zhao <caspian.zhao@outlook.com>
|
|
3
|
-
Date: Thu, 20 Aug 2026 09:25:59 -0700
|
|
4
|
-
Subject: [PATCH 1/2] feat: scope indexing to selected project roots
|
|
5
|
-
|
|
6
|
-
---
|
|
7
|
-
internal/sources/claude.go | 19 ++-
|
|
8
|
-
internal/sources/codex.go | 16 +++
|
|
9
|
-
internal/sources/cursor.go | 4 +
|
|
10
|
-
internal/sources/project_scope.go | 160 +++++++++++++++++++++++++
|
|
11
|
-
internal/sources/project_scope_test.go | 133 ++++++++++++++++++++
|
|
12
|
-
internal/sources/scan_progress.go | 31 +++++
|
|
13
|
-
internal/sources/util.go | 1 +
|
|
14
|
-
7 files changed, 363 insertions(+), 1 deletion(-)
|
|
15
|
-
create mode 100644 internal/sources/project_scope.go
|
|
16
|
-
create mode 100644 internal/sources/project_scope_test.go
|
|
17
|
-
create mode 100644 internal/sources/scan_progress.go
|
|
18
|
-
|
|
19
|
-
diff --git a/internal/sources/claude.go b/internal/sources/claude.go
|
|
20
|
-
index 25258c9..ded140c 100644
|
|
21
|
-
--- a/internal/sources/claude.go
|
|
22
|
-
+++ b/internal/sources/claude.go
|
|
23
|
-
@@ -37,7 +37,24 @@ func LoadClaude() []model.Session {
|
|
24
|
-
// ClaudeFiles lists the transcript files under the Claude root without parsing
|
|
25
|
-
// them — a cheap count for diagnostics.
|
|
26
|
-
func ClaudeFiles() []string {
|
|
27
|
-
- return walkFiles(ClaudeRoot(), ClaudeFileWanted)
|
|
28
|
-
+ files := walkFiles(ClaudeRoot(), ClaudeFileWanted)
|
|
29
|
-
+ if len(projectScopeRoots()) == 0 {
|
|
30
|
-
+ return files
|
|
31
|
-
+ }
|
|
32
|
-
+ out := make([]string, 0, len(files))
|
|
33
|
-
+ for _, path := range files {
|
|
34
|
-
+ base := ClaudeProjectDirBase(path)
|
|
35
|
-
+ if resolved := ResolveEncodedPath(base); resolved != "" {
|
|
36
|
-
+ if projectScopeMatchesPath(resolved) {
|
|
37
|
-
+ out = append(out, path)
|
|
38
|
-
+ }
|
|
39
|
-
+ continue
|
|
40
|
-
+ }
|
|
41
|
-
+ if projectScopeMatchesProject(claudeProjectName(claudeProjectDir(path))) {
|
|
42
|
-
+ out = append(out, path)
|
|
43
|
-
+ }
|
|
44
|
-
+ }
|
|
45
|
-
+ return out
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
// ClaudeFileWanted reports whether a path under the Claude root belongs in
|
|
49
|
-
diff --git a/internal/sources/codex.go b/internal/sources/codex.go
|
|
50
|
-
index 6c924cc..4d02567 100644
|
|
51
|
-
--- a/internal/sources/codex.go
|
|
52
|
-
+++ b/internal/sources/codex.go
|
|
53
|
-
@@ -29,6 +29,7 @@ func CodexRoot() string {
|
|
54
|
-
func LoadCodex() []model.Session {
|
|
55
|
-
root := CodexRoot()
|
|
56
|
-
files := walkFiles(filepath.Join(root, "sessions"), codexRolloutWanted)
|
|
57
|
-
+ files = scopeCodexRollouts(files)
|
|
58
|
-
ss := parseFiles(files, ParseCodexRollout)
|
|
59
|
-
// history.jsonl repeats the prompts of sessions whose rollout deja has
|
|
60
|
-
// already read, with a coarser timestamp — so the ingest de-duplicator,
|
|
61
|
-
@@ -58,12 +59,27 @@ func codexRolloutWanted(p string) bool {
|
|
62
|
-
func CodexFiles() []string {
|
|
63
|
-
root := CodexRoot()
|
|
64
|
-
files := walkFiles(filepath.Join(root, "sessions"), codexRolloutWanted)
|
|
65
|
-
+ files = scopeCodexRollouts(files)
|
|
66
|
-
if hist := filepath.Join(root, "history.jsonl"); fileExists(hist) {
|
|
67
|
-
files = append(files, hist)
|
|
68
|
-
}
|
|
69
|
-
return files
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
+func scopeCodexRollouts(files []string) []string {
|
|
73
|
-
+ if len(projectScopeRoots()) == 0 {
|
|
74
|
-
+ return files
|
|
75
|
-
+ }
|
|
76
|
-
+ out := make([]string, 0, len(files))
|
|
77
|
-
+ for _, path := range files {
|
|
78
|
-
+ _, cwd := codexRolloutHead(path)
|
|
79
|
-
+ if projectScopeMatchesPath(cwd) {
|
|
80
|
-
+ out = append(out, path)
|
|
81
|
-
+ }
|
|
82
|
-
+ }
|
|
83
|
-
+ return out
|
|
84
|
-
+}
|
|
85
|
-
+
|
|
86
|
-
func ParseCodexHistory(path string) ([]model.Session, error) {
|
|
87
|
-
return ParseCodexHistoryFromOffset(path, 0)
|
|
88
|
-
}
|
|
89
|
-
diff --git a/internal/sources/cursor.go b/internal/sources/cursor.go
|
|
90
|
-
index 3cb1568..7899d4a 100644
|
|
91
|
-
--- a/internal/sources/cursor.go
|
|
92
|
-
+++ b/internal/sources/cursor.go
|
|
93
|
-
@@ -95,6 +95,9 @@ func CursorTranscripts() []string {
|
|
94
|
-
return nil
|
|
95
|
-
}
|
|
96
|
-
if cursorPathHasDir(p, "agent-transcripts") {
|
|
97
|
-
+ if len(projectScopeRoots()) > 0 && !projectScopeMatchesProject(cursorTranscriptProject(p)) {
|
|
98
|
-
+ return nil
|
|
99
|
-
+ }
|
|
100
|
-
out = append(out, p)
|
|
101
|
-
}
|
|
102
|
-
return nil
|
|
103
|
-
@@ -123,6 +126,7 @@ func fileExists(p string) bool {
|
|
104
|
-
func LoadCursor() []model.Session {
|
|
105
|
-
var ss []model.Session
|
|
106
|
-
for _, db := range CursorDBs() {
|
|
107
|
-
+ reportScanPath(db)
|
|
108
|
-
got, _ := ParseCursorDB(db)
|
|
109
|
-
ss = append(ss, got...)
|
|
110
|
-
}
|
|
111
|
-
diff --git a/internal/sources/project_scope.go b/internal/sources/project_scope.go
|
|
112
|
-
new file mode 100644
|
|
113
|
-
index 0000000..78e6add
|
|
114
|
-
--- /dev/null
|
|
115
|
-
+++ b/internal/sources/project_scope.go
|
|
116
|
-
@@ -0,0 +1,160 @@
|
|
117
|
-
+package sources
|
|
118
|
-
+
|
|
119
|
-
+import (
|
|
120
|
-
+ "net/url"
|
|
121
|
-
+ "os"
|
|
122
|
-
+ "os/exec"
|
|
123
|
-
+ "path/filepath"
|
|
124
|
-
+ "strings"
|
|
125
|
-
+ "sync"
|
|
126
|
-
+)
|
|
127
|
-
+
|
|
128
|
-
+type scopeRepositoryIdentity struct {
|
|
129
|
-
+ commonDir string
|
|
130
|
-
+ remote string
|
|
131
|
-
+}
|
|
132
|
-
+
|
|
133
|
-
+var scopeRepositoryCache sync.Map
|
|
134
|
-
+
|
|
135
|
-
+// projectScopeRoots is the private runtime boundary used by Dosu Drive. An
|
|
136
|
-
+// empty value preserves deja's normal all-history behavior. Paths use the host
|
|
137
|
-
+// path-list separator so more than one selected repository can be supplied.
|
|
138
|
-
+func projectScopeRoots() []string {
|
|
139
|
-
+ raw := os.Getenv("DEJA_PROJECT_ROOTS")
|
|
140
|
-
+ if raw == "" {
|
|
141
|
-
+ return nil
|
|
142
|
-
+ }
|
|
143
|
-
+ var roots []string
|
|
144
|
-
+ seen := map[string]bool{}
|
|
145
|
-
+ for _, value := range filepath.SplitList(raw) {
|
|
146
|
-
+ value = canonicalScopePath(value)
|
|
147
|
-
+ if value == "" || seen[value] {
|
|
148
|
-
+ continue
|
|
149
|
-
+ }
|
|
150
|
-
+ seen[value] = true
|
|
151
|
-
+ roots = append(roots, value)
|
|
152
|
-
+ }
|
|
153
|
-
+ return roots
|
|
154
|
-
+}
|
|
155
|
-
+
|
|
156
|
-
+func canonicalScopePath(path string) string {
|
|
157
|
-
+ path = strings.TrimSpace(path)
|
|
158
|
-
+ if path == "" {
|
|
159
|
-
+ return ""
|
|
160
|
-
+ }
|
|
161
|
-
+ if absolute, err := filepath.Abs(path); err == nil {
|
|
162
|
-
+ path = absolute
|
|
163
|
-
+ }
|
|
164
|
-
+ if resolved, err := filepath.EvalSymlinks(path); err == nil {
|
|
165
|
-
+ path = resolved
|
|
166
|
-
+ }
|
|
167
|
-
+ return filepath.Clean(path)
|
|
168
|
-
+}
|
|
169
|
-
+
|
|
170
|
-
+func projectScopeMatchesPath(path string) bool {
|
|
171
|
-
+ path = canonicalScopePath(path)
|
|
172
|
-
+ if path == "" {
|
|
173
|
-
+ return false
|
|
174
|
-
+ }
|
|
175
|
-
+ for _, root := range projectScopeRoots() {
|
|
176
|
-
+ if rel, err := filepath.Rel(root, path); err == nil &&
|
|
177
|
-
+ (rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) && !filepath.IsAbs(rel))) {
|
|
178
|
-
+ return true
|
|
179
|
-
+ }
|
|
180
|
-
+ }
|
|
181
|
-
+ if candidate, ok := scopeRepositoryAt(path); ok {
|
|
182
|
-
+ for _, root := range projectScopeRoots() {
|
|
183
|
-
+ selected, selectedOK := scopeRepositoryAt(root)
|
|
184
|
-
+ if !selectedOK {
|
|
185
|
-
+ continue
|
|
186
|
-
+ }
|
|
187
|
-
+ if candidate.commonDir != "" && candidate.commonDir == selected.commonDir {
|
|
188
|
-
+ return true
|
|
189
|
-
+ }
|
|
190
|
-
+ if candidate.remote != "" && candidate.remote == selected.remote {
|
|
191
|
-
+ return true
|
|
192
|
-
+ }
|
|
193
|
-
+ }
|
|
194
|
-
+ // An existing repository is identifiable. A different remote with the
|
|
195
|
-
+ // same basename is a different project, not a related worktree.
|
|
196
|
-
+ return false
|
|
197
|
-
+ }
|
|
198
|
-
+ // Deleted worktrees no longer have Git metadata to prove. Keep the current
|
|
199
|
-
+ // whole-segment fallback so historical sessions from a removed worktree do
|
|
200
|
-
+ // not disappear merely because its checkout was cleaned up.
|
|
201
|
-
+ return projectScopeMatchesProject(projectName(path))
|
|
202
|
-
+}
|
|
203
|
-
+
|
|
204
|
-
+func scopeRepositoryAt(path string) (scopeRepositoryIdentity, bool) {
|
|
205
|
-
+ path = canonicalScopePath(path)
|
|
206
|
-
+ if path == "" {
|
|
207
|
-
+ return scopeRepositoryIdentity{}, false
|
|
208
|
-
+ }
|
|
209
|
-
+ if cached, ok := scopeRepositoryCache.Load(path); ok {
|
|
210
|
-
+ identity := cached.(scopeRepositoryIdentity)
|
|
211
|
-
+ return identity, identity.commonDir != "" || identity.remote != ""
|
|
212
|
-
+ }
|
|
213
|
-
+ if _, err := os.Stat(path); err != nil {
|
|
214
|
-
+ scopeRepositoryCache.Store(path, scopeRepositoryIdentity{})
|
|
215
|
-
+ return scopeRepositoryIdentity{}, false
|
|
216
|
-
+ }
|
|
217
|
-
+ identity := scopeRepositoryIdentity{
|
|
218
|
-
+ commonDir: gitScopeValue(path, "rev-parse", "--path-format=absolute", "--git-common-dir"),
|
|
219
|
-
+ remote: normalizeScopeRemote(gitScopeValue(path, "config", "--get", "remote.origin.url")),
|
|
220
|
-
+ }
|
|
221
|
-
+ identity.commonDir = canonicalScopePath(identity.commonDir)
|
|
222
|
-
+ scopeRepositoryCache.Store(path, identity)
|
|
223
|
-
+ return identity, identity.commonDir != "" || identity.remote != ""
|
|
224
|
-
+}
|
|
225
|
-
+
|
|
226
|
-
+func gitScopeValue(path string, args ...string) string {
|
|
227
|
-
+ cmdArgs := append([]string{"-C", path}, args...)
|
|
228
|
-
+ out, err := exec.Command("git", cmdArgs...).Output()
|
|
229
|
-
+ if err != nil {
|
|
230
|
-
+ return ""
|
|
231
|
-
+ }
|
|
232
|
-
+ return strings.TrimSpace(string(out))
|
|
233
|
-
+}
|
|
234
|
-
+
|
|
235
|
-
+func normalizeScopeRemote(remote string) string {
|
|
236
|
-
+ remote = strings.TrimSpace(remote)
|
|
237
|
-
+ if remote == "" {
|
|
238
|
-
+ return ""
|
|
239
|
-
+ }
|
|
240
|
-
+ if parsed, err := url.Parse(remote); err == nil && parsed.Host != "" {
|
|
241
|
-
+ remote = parsed.Host + "/" + strings.TrimPrefix(parsed.Path, "/")
|
|
242
|
-
+ } else if colon := strings.Index(remote, ":"); colon > 0 && !filepath.IsAbs(remote) {
|
|
243
|
-
+ host := remote[:colon]
|
|
244
|
-
+ if at := strings.LastIndex(host, "@"); at >= 0 {
|
|
245
|
-
+ host = host[at+1:]
|
|
246
|
-
+ }
|
|
247
|
-
+ remote = host + "/" + strings.TrimPrefix(remote[colon+1:], "/")
|
|
248
|
-
+ }
|
|
249
|
-
+ remote = strings.TrimSuffix(strings.TrimSuffix(remote, "/"), ".git")
|
|
250
|
-
+ return strings.ToLower(remote)
|
|
251
|
-
+}
|
|
252
|
-
+
|
|
253
|
-
+// projectScopeMatchesProject keeps the project-name fallback deja and Dosu
|
|
254
|
-
+// already expose: another checkout/worktree named "dosu" belongs to a selected
|
|
255
|
-
+// "dosu" repository. Exact-path matching above wins first; this fallback is
|
|
256
|
-
+// deliberately no broader than whole path segments.
|
|
257
|
-
+func projectScopeMatchesProject(project string) bool {
|
|
258
|
-
+ project = normalizeScopeProject(project)
|
|
259
|
-
+ if project == "" || project == "-" {
|
|
260
|
-
+ return false
|
|
261
|
-
+ }
|
|
262
|
-
+ for _, root := range projectScopeRoots() {
|
|
263
|
-
+ base := normalizeScopeProject(filepath.Base(root))
|
|
264
|
-
+ parentBase := normalizeScopeProject(filepath.Join(filepath.Base(filepath.Dir(root)), filepath.Base(root)))
|
|
265
|
-
+ if project == base || project == parentBase || strings.HasSuffix(project, "/"+base) {
|
|
266
|
-
+ return true
|
|
267
|
-
+ }
|
|
268
|
-
+ }
|
|
269
|
-
+ return false
|
|
270
|
-
+}
|
|
271
|
-
+
|
|
272
|
-
+func normalizeScopeProject(project string) string {
|
|
273
|
-
+ project = strings.ReplaceAll(strings.TrimSpace(project), `\`, "/")
|
|
274
|
-
+ project = strings.TrimPrefix(project, "imported:")
|
|
275
|
-
+ return strings.ToLower(strings.Trim(project, "/"))
|
|
276
|
-
+}
|
|
277
|
-
diff --git a/internal/sources/project_scope_test.go b/internal/sources/project_scope_test.go
|
|
278
|
-
new file mode 100644
|
|
279
|
-
index 0000000..9986630
|
|
280
|
-
--- /dev/null
|
|
281
|
-
+++ b/internal/sources/project_scope_test.go
|
|
282
|
-
@@ -0,0 +1,133 @@
|
|
283
|
-
+package sources
|
|
284
|
-
+
|
|
285
|
-
+import (
|
|
286
|
-
+ "encoding/json"
|
|
287
|
-
+ "os"
|
|
288
|
-
+ "os/exec"
|
|
289
|
-
+ "path/filepath"
|
|
290
|
-
+ "slices"
|
|
291
|
-
+ "strings"
|
|
292
|
-
+ "testing"
|
|
293
|
-
+)
|
|
294
|
-
+
|
|
295
|
-
+func TestCodexFilesHonorsProjectRootScopeBeforeParsing(t *testing.T) {
|
|
296
|
-
+ root := t.TempDir()
|
|
297
|
-
+ selected := filepath.Join(root, "selected", "dosu")
|
|
298
|
-
+ related := filepath.Join(root, "another", "dosu")
|
|
299
|
-
+ unrelatedSameName := filepath.Join(root, "unrelated", "dosu")
|
|
300
|
-
+ deletedWorktree := filepath.Join(root, "deleted", "dosu")
|
|
301
|
-
+ initScopeRepository(t, selected, "git@example.com:dosu-ai/dosu.git")
|
|
302
|
-
+ initScopeRepository(t, related, "https://example.com/dosu-ai/dosu.git")
|
|
303
|
-
+ initScopeRepository(t, unrelatedSameName, "https://example.com/someone-else/dosu.git")
|
|
304
|
-
+ codex := filepath.Join(root, "codex")
|
|
305
|
-
+ t.Setenv("DEJA_CODEX_ROOT", codex)
|
|
306
|
-
+ t.Setenv("DEJA_PROJECT_ROOTS", selected)
|
|
307
|
-
+
|
|
308
|
-
+ want := filepath.Join(codex, "sessions", "2026", "08", "rollout-want.jsonl")
|
|
309
|
-
+ sameProjectOtherCheckout := filepath.Join(codex, "sessions", "2026", "08", "rollout-related.jsonl")
|
|
310
|
-
+ deletedProjectCheckout := filepath.Join(codex, "sessions", "2026", "08", "rollout-deleted.jsonl")
|
|
311
|
-
+ reject := filepath.Join(codex, "sessions", "2026", "08", "rollout-reject.jsonl")
|
|
312
|
-
+ writeCodexScopeFixture(t, want, selected, "want")
|
|
313
|
-
+ writeCodexScopeFixture(t, sameProjectOtherCheckout, related, "related")
|
|
314
|
-
+ writeCodexScopeFixture(t, deletedProjectCheckout, deletedWorktree, "deleted")
|
|
315
|
-
+ writeCodexScopeFixture(t, reject, unrelatedSameName, "reject")
|
|
316
|
-
+ history := filepath.Join(codex, "history.jsonl")
|
|
317
|
-
+ writeScopeFile(t, history, `{"session_id":"history-only","ts":1,"text":"keep history collision semantics"}`+"\n")
|
|
318
|
-
+
|
|
319
|
-
+ got := CodexFiles()
|
|
320
|
-
+ slices.Sort(got)
|
|
321
|
-
+ wantFiles := []string{deletedProjectCheckout, history, sameProjectOtherCheckout, want}
|
|
322
|
-
+ slices.Sort(wantFiles)
|
|
323
|
-
+ if !slices.Equal(got, wantFiles) {
|
|
324
|
-
+ t.Fatalf("CodexFiles() = %v, want %v", got, wantFiles)
|
|
325
|
-
+ }
|
|
326
|
-
+}
|
|
327
|
-
+
|
|
328
|
-
+func TestScanProgressLineIsMachineReadableAndEscapesPaths(t *testing.T) {
|
|
329
|
-
+ line := scanProgressLine("/tmp/a\nstrange.jsonl")
|
|
330
|
-
+ if !strings.HasPrefix(line, scanProgressPrefix) {
|
|
331
|
-
+ t.Fatalf("progress line = %q", line)
|
|
332
|
-
+ }
|
|
333
|
-
+ var event struct {
|
|
334
|
-
+ Path string `json:"path"`
|
|
335
|
-
+ }
|
|
336
|
-
+ if err := json.Unmarshal([]byte(strings.TrimPrefix(line, scanProgressPrefix)), &event); err != nil {
|
|
337
|
-
+ t.Fatal(err)
|
|
338
|
-
+ }
|
|
339
|
-
+ if event.Path != "/tmp/a\nstrange.jsonl" {
|
|
340
|
-
+ t.Fatalf("path = %q", event.Path)
|
|
341
|
-
+ }
|
|
342
|
-
+}
|
|
343
|
-
+
|
|
344
|
-
+func TestClaudeFilesHonorsProjectRootScopeBeforeParsing(t *testing.T) {
|
|
345
|
-
+ root := t.TempDir()
|
|
346
|
-
+ selected := filepath.Join(root, "work", "dosu")
|
|
347
|
-
+ other := filepath.Join(root, "work", "other")
|
|
348
|
-
+ for _, dir := range []string{selected, other} {
|
|
349
|
-
+ if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
350
|
-
+ t.Fatal(err)
|
|
351
|
-
+ }
|
|
352
|
-
+ }
|
|
353
|
-
+ claude := filepath.Join(root, "claude-projects")
|
|
354
|
-
+ t.Setenv("DEJA_CLAUDE_ROOT", claude)
|
|
355
|
-
+ t.Setenv("DEJA_PROJECT_ROOTS", selected)
|
|
356
|
-
+ want := filepath.Join(claude, strings.ReplaceAll(selected, string(filepath.Separator), "-"), "want.jsonl")
|
|
357
|
-
+ reject := filepath.Join(claude, strings.ReplaceAll(other, string(filepath.Separator), "-"), "reject.jsonl")
|
|
358
|
-
+ writeScopeFile(t, want, `{}`+"\n")
|
|
359
|
-
+ writeScopeFile(t, reject, `{}`+"\n")
|
|
360
|
-
+
|
|
361
|
-
+ if got := ClaudeFiles(); !slices.Equal(got, []string{want}) {
|
|
362
|
-
+ t.Fatalf("ClaudeFiles() = %v, want [%s]", got, want)
|
|
363
|
-
+ }
|
|
364
|
-
+}
|
|
365
|
-
+
|
|
366
|
-
+func TestCursorTranscriptsHonorProjectRootScopeBeforeParsing(t *testing.T) {
|
|
367
|
-
+ root := t.TempDir()
|
|
368
|
-
+ selected := filepath.Join(root, "work", "dosu")
|
|
369
|
-
+ other := filepath.Join(root, "work", "other")
|
|
370
|
-
+ for _, dir := range []string{selected, other} {
|
|
371
|
-
+ if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
372
|
-
+ t.Fatal(err)
|
|
373
|
-
+ }
|
|
374
|
-
+ }
|
|
375
|
-
+ cursor := filepath.Join(root, "cursor")
|
|
376
|
-
+ t.Setenv("DEJA_CURSOR_CLI_ROOT", cursor)
|
|
377
|
-
+ t.Setenv("DEJA_PROJECT_ROOTS", selected)
|
|
378
|
-
+ want := filepath.Join(cursor, "projects", strings.TrimPrefix(strings.ReplaceAll(selected, string(filepath.Separator), "-"), "-"), "agent-transcripts", "want.jsonl")
|
|
379
|
-
+ reject := filepath.Join(cursor, "projects", strings.TrimPrefix(strings.ReplaceAll(other, string(filepath.Separator), "-"), "-"), "agent-transcripts", "reject.jsonl")
|
|
380
|
-
+ writeScopeFile(t, want, `{}`+"\n")
|
|
381
|
-
+ writeScopeFile(t, reject, `{}`+"\n")
|
|
382
|
-
+
|
|
383
|
-
+ if got := CursorTranscripts(); !slices.Equal(got, []string{want}) {
|
|
384
|
-
+ t.Fatalf("CursorTranscripts() = %v, want [%s]", got, want)
|
|
385
|
-
+ }
|
|
386
|
-
+}
|
|
387
|
-
+
|
|
388
|
-
+func writeCodexScopeFixture(t *testing.T, path, cwd, id string) {
|
|
389
|
-
+ t.Helper()
|
|
390
|
-
+ writeScopeFile(t, path,
|
|
391
|
-
+ `{"type":"session_meta","payload":{"id":"`+id+`","cwd":"`+cwd+`"}}`+"\n"+
|
|
392
|
-
+ `{"type":"response_item","payload":{"role":"user","content":[{"type":"input_text","text":"hello"}]}}`+"\n")
|
|
393
|
-
+}
|
|
394
|
-
+
|
|
395
|
-
+func writeScopeFile(t *testing.T, path, body string) {
|
|
396
|
-
+ t.Helper()
|
|
397
|
-
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
398
|
-
+ t.Fatal(err)
|
|
399
|
-
+ }
|
|
400
|
-
+ if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
|
401
|
-
+ t.Fatal(err)
|
|
402
|
-
+ }
|
|
403
|
-
+}
|
|
404
|
-
+
|
|
405
|
-
+func initScopeRepository(t *testing.T, path, remote string) {
|
|
406
|
-
+ t.Helper()
|
|
407
|
-
+ if err := os.MkdirAll(path, 0o755); err != nil {
|
|
408
|
-
+ t.Fatal(err)
|
|
409
|
-
+ }
|
|
410
|
-
+ for _, args := range [][]string{{"init", "-q", path}, {"-C", path, "remote", "add", "origin", remote}} {
|
|
411
|
-
+ if output, err := exec.Command("git", args...).CombinedOutput(); err != nil {
|
|
412
|
-
+ t.Fatalf("git %v: %v: %s", args, err, output)
|
|
413
|
-
+ }
|
|
414
|
-
+ }
|
|
415
|
-
+}
|
|
416
|
-
diff --git a/internal/sources/scan_progress.go b/internal/sources/scan_progress.go
|
|
417
|
-
new file mode 100644
|
|
418
|
-
index 0000000..da03350
|
|
419
|
-
--- /dev/null
|
|
420
|
-
+++ b/internal/sources/scan_progress.go
|
|
421
|
-
@@ -0,0 +1,31 @@
|
|
422
|
-
+package sources
|
|
423
|
-
+
|
|
424
|
-
+import (
|
|
425
|
-
+ "encoding/json"
|
|
426
|
-
+ "fmt"
|
|
427
|
-
+ "os"
|
|
428
|
-
+ "sync"
|
|
429
|
-
+)
|
|
430
|
-
+
|
|
431
|
-
+const scanProgressPrefix = "@dosu-scan "
|
|
432
|
-
+
|
|
433
|
-
+var scanProgressMu sync.Mutex
|
|
434
|
-
+
|
|
435
|
-
+func scanProgressLine(path string) string {
|
|
436
|
-
+ payload, _ := json.Marshal(struct {
|
|
437
|
-
+ Path string `json:"path"`
|
|
438
|
-
+ }{Path: path})
|
|
439
|
-
+ return scanProgressPrefix + string(payload)
|
|
440
|
-
+}
|
|
441
|
-
+
|
|
442
|
-
+// reportScanPath is inert for normal deja users. Dosu's private runtime turns
|
|
443
|
-
+// it on and consumes the machine-readable stderr lines to update one spinner
|
|
444
|
-
+// in place; the paths never accumulate in the terminal scrollback.
|
|
445
|
-
+func reportScanPath(path string) {
|
|
446
|
-
+ if os.Getenv("DEJA_SCAN_PROGRESS") != "1" {
|
|
447
|
-
+ return
|
|
448
|
-
+ }
|
|
449
|
-
+ scanProgressMu.Lock()
|
|
450
|
-
+ defer scanProgressMu.Unlock()
|
|
451
|
-
+ fmt.Fprintln(os.Stderr, scanProgressLine(path))
|
|
452
|
-
+}
|
|
453
|
-
diff --git a/internal/sources/util.go b/internal/sources/util.go
|
|
454
|
-
index bbf9081..e5dc6ba 100644
|
|
455
|
-
--- a/internal/sources/util.go
|
|
456
|
-
+++ b/internal/sources/util.go
|
|
457
|
-
@@ -330,6 +330,7 @@ func parseFiles(files []string, parse func(string) ([]model.Session, error)) []m
|
|
458
|
-
go func() {
|
|
459
|
-
defer wg.Done()
|
|
460
|
-
for j := range jobs {
|
|
461
|
-
+ reportScanPath(j.p)
|
|
462
|
-
ss, err := safeParse(j.p, parse)
|
|
463
|
-
diagFileError(j.p, err)
|
|
464
|
-
outs <- struct {
|
|
465
|
-
--
|
|
466
|
-
2.50.1 (Apple Git-155)
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
From e23e2af7b630aa864472db9fb28bec124a39cc04 Mon Sep 17 00:00:00 2001
|
|
470
|
-
From: Caspian Zhao <caspian.zhao@outlook.com>
|
|
471
|
-
Date: Thu, 20 Aug 2026 09:47:48 -0700
|
|
472
|
-
Subject: [PATCH 2/2] fix: retain scoped repository identity
|
|
473
|
-
|
|
474
|
-
---
|
|
475
|
-
internal/sources/claude.go | 13 ++++
|
|
476
|
-
internal/sources/claude_decode.go | 3 +
|
|
477
|
-
internal/sources/cursor.go | 55 ++++++++++++++---
|
|
478
|
-
internal/sources/project_scope.go | 51 ++++++++++++----
|
|
479
|
-
internal/sources/project_scope_test.go | 85 +++++++++++++++++++-------
|
|
480
|
-
5 files changed, 163 insertions(+), 44 deletions(-)
|
|
481
|
-
|
|
482
|
-
diff --git a/internal/sources/claude.go b/internal/sources/claude.go
|
|
483
|
-
index ded140c..9d70991 100644
|
|
484
|
-
--- a/internal/sources/claude.go
|
|
485
|
-
+++ b/internal/sources/claude.go
|
|
486
|
-
@@ -136,9 +136,22 @@ func parseClaudeGenericFromOffset(path string, offset int64) ([]model.Session, e
|
|
487
|
-
if p := projectFromPaths(s.Messages); p != "" {
|
|
488
|
-
s.Project = p
|
|
489
|
-
}
|
|
490
|
-
+ if root := claudeScopeRoot(path); root != "" {
|
|
491
|
-
+ s.Project = root
|
|
492
|
-
+ }
|
|
493
|
-
return []model.Session{s}, err
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
+func claudeScopeRoot(path string) string {
|
|
497
|
-
+ if len(projectScopeRoots()) == 0 {
|
|
498
|
-
+ return ""
|
|
499
|
-
+ }
|
|
500
|
-
+ if resolved := ResolveEncodedPath(ClaudeProjectDirBase(path)); resolved != "" {
|
|
501
|
-
+ return projectScopeRootForPath(resolved)
|
|
502
|
-
+ }
|
|
503
|
-
+ return projectScopeRootForProject(claudeProjectName(claudeProjectDir(path)))
|
|
504
|
-
+}
|
|
505
|
-
+
|
|
506
|
-
func claudeProjectDir(path string) string {
|
|
507
|
-
return projectDir(ClaudeRoot(), path)
|
|
508
|
-
}
|
|
509
|
-
diff --git a/internal/sources/claude_decode.go b/internal/sources/claude_decode.go
|
|
510
|
-
index 85b2678..7651f53 100644
|
|
511
|
-
--- a/internal/sources/claude_decode.go
|
|
512
|
-
+++ b/internal/sources/claude_decode.go
|
|
513
|
-
@@ -103,6 +103,9 @@ func parseClaudeTypedFromOffset(path string, offset int64) ([]model.Session, err
|
|
514
|
-
if p := projectFromPaths(s.Messages); p != "" {
|
|
515
|
-
s.Project = p
|
|
516
|
-
}
|
|
517
|
-
+ if root := claudeScopeRoot(path); root != "" {
|
|
518
|
-
+ s.Project = root
|
|
519
|
-
+ }
|
|
520
|
-
return []model.Session{s}, err
|
|
521
|
-
}
|
|
522
|
-
|
|
523
|
-
diff --git a/internal/sources/cursor.go b/internal/sources/cursor.go
|
|
524
|
-
index 7899d4a..c4a24a5 100644
|
|
525
|
-
--- a/internal/sources/cursor.go
|
|
526
|
-
+++ b/internal/sources/cursor.go
|
|
527
|
-
@@ -95,7 +95,7 @@ func CursorTranscripts() []string {
|
|
528
|
-
return nil
|
|
529
|
-
}
|
|
530
|
-
if cursorPathHasDir(p, "agent-transcripts") {
|
|
531
|
-
- if len(projectScopeRoots()) > 0 && !projectScopeMatchesProject(cursorTranscriptProject(p)) {
|
|
532
|
-
+ if len(projectScopeRoots()) > 0 && cursorTranscriptScopeRoot(p) == "" {
|
|
533
|
-
return nil
|
|
534
|
-
}
|
|
535
|
-
out = append(out, p)
|
|
536
|
-
@@ -199,6 +199,7 @@ func parseCursorDB(db string, sinceMS int64) ([]model.Session, error) {
|
|
537
|
-
cid = strings.TrimPrefix(str(c["key"]), "composerData:")
|
|
538
|
-
}
|
|
539
|
-
s := model.Session{Harness: "cursor", ID: cid, Project: "-", Path: db, Title: str(c["name"])}
|
|
540
|
-
+ workspace := ""
|
|
541
|
-
s.Touch(epochMS(c["created"]))
|
|
542
|
-
s.Touch(epochMS(c["updated"]))
|
|
543
|
-
bs := byComposer[cid]
|
|
544
|
-
@@ -221,11 +222,19 @@ func parseCursorDB(db string, sinceMS int64) ([]model.Session, error) {
|
|
545
|
-
s.Messages = append(s.Messages, model.Message{Role: role, Text: text, Time: t})
|
|
546
|
-
if s.Project == "-" {
|
|
547
|
-
if ws := str(b["wsdir"]); ws != "" {
|
|
548
|
-
+ workspace = ws
|
|
549
|
-
s.Project = projectName(ws)
|
|
550
|
-
}
|
|
551
|
-
}
|
|
552
|
-
}
|
|
553
|
-
if len(s.Messages) > 0 {
|
|
554
|
-
+ if len(projectScopeRoots()) > 0 {
|
|
555
|
-
+ root := projectScopeRootForPath(workspace)
|
|
556
|
-
+ if root == "" {
|
|
557
|
-
+ continue
|
|
558
|
-
+ }
|
|
559
|
-
+ s.Project = root
|
|
560
|
-
+ }
|
|
561
|
-
out = append(out, s)
|
|
562
|
-
}
|
|
563
|
-
}
|
|
564
|
-
@@ -284,7 +293,11 @@ var cursorDialect = toolDialect{
|
|
565
|
-
// JSONL; control lines (turn_ended etc.) carry no role and are skipped.
|
|
566
|
-
func ParseCursorTranscript(path string) ([]model.Session, error) {
|
|
567
|
-
id := strings.TrimSuffix(filepath.Base(path), ".jsonl")
|
|
568
|
-
- s := model.Session{Harness: "cursor", ID: id, Project: cursorTranscriptProject(path), Path: path}
|
|
569
|
-
+ project := cursorTranscriptProject(path)
|
|
570
|
-
+ if root := cursorTranscriptScopeRoot(path); root != "" {
|
|
571
|
-
+ project = root
|
|
572
|
-
+ }
|
|
573
|
-
+ s := model.Session{Harness: "cursor", ID: id, Project: project, Path: path}
|
|
574
|
-
fi, err := os.Stat(path)
|
|
575
|
-
if err == nil {
|
|
576
|
-
s.Touch(fi.ModTime()) // transcripts carry no timestamps
|
|
577
|
-
@@ -330,24 +343,46 @@ func ParseCursorTranscript(path string) ([]model.Session, error) {
|
|
578
|
-
// back to a path with a greedy existence-checked walk; hyphens in real dir
|
|
579
|
-
// names survive because the literal branch is tried when the split fails.
|
|
580
|
-
func cursorTranscriptProject(path string) string {
|
|
581
|
-
+ encoded := cursorTranscriptEncodedProject(path)
|
|
582
|
-
+ if encoded == "" {
|
|
583
|
-
+ return "-"
|
|
584
|
-
+ }
|
|
585
|
-
+ if resolved := resolveEncodedPath("-" + encoded); resolved != "" {
|
|
586
|
-
+ return projectName(resolved)
|
|
587
|
-
+ }
|
|
588
|
-
+ parts := strings.Split(encoded, "-")
|
|
589
|
-
+ if len(parts) >= 2 {
|
|
590
|
-
+ return filepath.Join(parts[len(parts)-2], parts[len(parts)-1])
|
|
591
|
-
+ }
|
|
592
|
-
+ return encoded
|
|
593
|
-
+}
|
|
594
|
-
+
|
|
595
|
-
+func cursorTranscriptEncodedProject(path string) string {
|
|
596
|
-
dir := filepath.Clean(path)
|
|
597
|
-
for !strings.EqualFold(filepath.Base(filepath.Dir(dir)), "projects") {
|
|
598
|
-
parent := filepath.Dir(dir)
|
|
599
|
-
if parent == dir {
|
|
600
|
-
- return "-"
|
|
601
|
-
+ return ""
|
|
602
|
-
}
|
|
603
|
-
dir = parent
|
|
604
|
-
}
|
|
605
|
-
encoded := filepath.Base(dir)
|
|
606
|
-
if encoded == "" || encoded == "." {
|
|
607
|
-
- return "-"
|
|
608
|
-
+ return ""
|
|
609
|
-
}
|
|
610
|
-
- if resolved := resolveEncodedPath("-" + encoded); resolved != "" {
|
|
611
|
-
- return projectName(resolved)
|
|
612
|
-
+ return encoded
|
|
613
|
-
+}
|
|
614
|
-
+
|
|
615
|
-
+func cursorTranscriptScopeRoot(path string) string {
|
|
616
|
-
+ if len(projectScopeRoots()) == 0 {
|
|
617
|
-
+ return ""
|
|
618
|
-
}
|
|
619
|
-
- parts := strings.Split(encoded, "-")
|
|
620
|
-
- if len(parts) >= 2 {
|
|
621
|
-
- return filepath.Join(parts[len(parts)-2], parts[len(parts)-1])
|
|
622
|
-
+ encoded := cursorTranscriptEncodedProject(path)
|
|
623
|
-
+ if encoded == "" {
|
|
624
|
-
+ return ""
|
|
625
|
-
}
|
|
626
|
-
- return encoded
|
|
627
|
-
+ if resolved := resolveEncodedPath("-" + encoded); resolved != "" {
|
|
628
|
-
+ return projectScopeRootForPath(resolved)
|
|
629
|
-
+ }
|
|
630
|
-
+ return projectScopeRootForProject(cursorTranscriptProject(path))
|
|
631
|
-
}
|
|
632
|
-
diff --git a/internal/sources/project_scope.go b/internal/sources/project_scope.go
|
|
633
|
-
index 78e6add..9cdd1de 100644
|
|
634
|
-
--- a/internal/sources/project_scope.go
|
|
635
|
-
+++ b/internal/sources/project_scope.go
|
|
636
|
-
@@ -52,37 +52,56 @@ func canonicalScopePath(path string) string {
|
|
637
|
-
}
|
|
638
|
-
|
|
639
|
-
func projectScopeMatchesPath(path string) bool {
|
|
640
|
-
+ return projectScopeRootForPath(path) != ""
|
|
641
|
-
+}
|
|
642
|
-
+
|
|
643
|
-
+// projectScopeRootForPath returns the one selected repository a source path
|
|
644
|
-
+// belongs to. Exact containment wins. A checkout elsewhere can still belong
|
|
645
|
-
+// through a shared Git common directory or normalized origin, but ambiguous
|
|
646
|
-
+// matches are rejected rather than assigned to an arbitrary selected root.
|
|
647
|
-
+func projectScopeRootForPath(path string) string {
|
|
648
|
-
path = canonicalScopePath(path)
|
|
649
|
-
if path == "" {
|
|
650
|
-
- return false
|
|
651
|
-
+ return ""
|
|
652
|
-
}
|
|
653
|
-
+ exact := ""
|
|
654
|
-
for _, root := range projectScopeRoots() {
|
|
655
|
-
if rel, err := filepath.Rel(root, path); err == nil &&
|
|
656
|
-
(rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) && !filepath.IsAbs(rel))) {
|
|
657
|
-
- return true
|
|
658
|
-
+ if len(root) > len(exact) {
|
|
659
|
-
+ exact = root
|
|
660
|
-
+ }
|
|
661
|
-
}
|
|
662
|
-
}
|
|
663
|
-
+ if exact != "" {
|
|
664
|
-
+ return exact
|
|
665
|
-
+ }
|
|
666
|
-
if candidate, ok := scopeRepositoryAt(path); ok {
|
|
667
|
-
+ matched := ""
|
|
668
|
-
for _, root := range projectScopeRoots() {
|
|
669
|
-
selected, selectedOK := scopeRepositoryAt(root)
|
|
670
|
-
if !selectedOK {
|
|
671
|
-
continue
|
|
672
|
-
}
|
|
673
|
-
- if candidate.commonDir != "" && candidate.commonDir == selected.commonDir {
|
|
674
|
-
- return true
|
|
675
|
-
- }
|
|
676
|
-
- if candidate.remote != "" && candidate.remote == selected.remote {
|
|
677
|
-
- return true
|
|
678
|
-
+ if (candidate.commonDir != "" && candidate.commonDir == selected.commonDir) ||
|
|
679
|
-
+ (candidate.remote != "" && candidate.remote == selected.remote) {
|
|
680
|
-
+ if matched != "" && matched != root {
|
|
681
|
-
+ return ""
|
|
682
|
-
+ }
|
|
683
|
-
+ matched = root
|
|
684
|
-
}
|
|
685
|
-
}
|
|
686
|
-
+ if matched != "" {
|
|
687
|
-
+ return matched
|
|
688
|
-
+ }
|
|
689
|
-
// An existing repository is identifiable. A different remote with the
|
|
690
|
-
// same basename is a different project, not a related worktree.
|
|
691
|
-
- return false
|
|
692
|
-
+ return ""
|
|
693
|
-
}
|
|
694
|
-
// Deleted worktrees no longer have Git metadata to prove. Keep the current
|
|
695
|
-
// whole-segment fallback so historical sessions from a removed worktree do
|
|
696
|
-
// not disappear merely because its checkout was cleaned up.
|
|
697
|
-
- return projectScopeMatchesProject(projectName(path))
|
|
698
|
-
+ return projectScopeRootForProject(projectName(path))
|
|
699
|
-
}
|
|
700
|
-
|
|
701
|
-
func scopeRepositoryAt(path string) (scopeRepositoryIdentity, bool) {
|
|
702
|
-
@@ -139,18 +158,26 @@ func normalizeScopeRemote(remote string) string {
|
|
703
|
-
// "dosu" repository. Exact-path matching above wins first; this fallback is
|
|
704
|
-
// deliberately no broader than whole path segments.
|
|
705
|
-
func projectScopeMatchesProject(project string) bool {
|
|
706
|
-
+ return projectScopeRootForProject(project) != ""
|
|
707
|
-
+}
|
|
708
|
-
+
|
|
709
|
-
+func projectScopeRootForProject(project string) string {
|
|
710
|
-
project = normalizeScopeProject(project)
|
|
711
|
-
if project == "" || project == "-" {
|
|
712
|
-
- return false
|
|
713
|
-
+ return ""
|
|
714
|
-
}
|
|
715
|
-
+ matched := ""
|
|
716
|
-
for _, root := range projectScopeRoots() {
|
|
717
|
-
base := normalizeScopeProject(filepath.Base(root))
|
|
718
|
-
parentBase := normalizeScopeProject(filepath.Join(filepath.Base(filepath.Dir(root)), filepath.Base(root)))
|
|
719
|
-
if project == base || project == parentBase || strings.HasSuffix(project, "/"+base) {
|
|
720
|
-
- return true
|
|
721
|
-
+ if matched != "" && matched != root {
|
|
722
|
-
+ return ""
|
|
723
|
-
+ }
|
|
724
|
-
+ matched = root
|
|
725
|
-
}
|
|
726
|
-
}
|
|
727
|
-
- return false
|
|
728
|
-
+ return matched
|
|
729
|
-
}
|
|
730
|
-
|
|
731
|
-
func normalizeScopeProject(project string) string {
|
|
732
|
-
diff --git a/internal/sources/project_scope_test.go b/internal/sources/project_scope_test.go
|
|
733
|
-
index 9986630..ecd22a2 100644
|
|
734
|
-
--- a/internal/sources/project_scope_test.go
|
|
735
|
-
+++ b/internal/sources/project_scope_test.go
|
|
736
|
-
@@ -62,45 +62,86 @@ func TestScanProgressLineIsMachineReadableAndEscapesPaths(t *testing.T) {
|
|
737
|
-
func TestClaudeFilesHonorsProjectRootScopeBeforeParsing(t *testing.T) {
|
|
738
|
-
root := t.TempDir()
|
|
739
|
-
selected := filepath.Join(root, "work", "dosu")
|
|
740
|
-
- other := filepath.Join(root, "work", "other")
|
|
741
|
-
- for _, dir := range []string{selected, other} {
|
|
742
|
-
- if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
743
|
-
- t.Fatal(err)
|
|
744
|
-
- }
|
|
745
|
-
- }
|
|
746
|
-
+ related := filepath.Join(root, "related", "dosu")
|
|
747
|
-
+ unrelatedSameName := filepath.Join(root, "unrelated", "dosu")
|
|
748
|
-
+ initScopeRepository(t, selected, "git@example.com:dosu-ai/dosu.git")
|
|
749
|
-
+ initScopeRepository(t, related, "https://example.com/dosu-ai/dosu.git")
|
|
750
|
-
+ initScopeRepository(t, unrelatedSameName, "https://example.com/someone-else/dosu.git")
|
|
751
|
-
claude := filepath.Join(root, "claude-projects")
|
|
752
|
-
t.Setenv("DEJA_CLAUDE_ROOT", claude)
|
|
753
|
-
t.Setenv("DEJA_PROJECT_ROOTS", selected)
|
|
754
|
-
want := filepath.Join(claude, strings.ReplaceAll(selected, string(filepath.Separator), "-"), "want.jsonl")
|
|
755
|
-
- reject := filepath.Join(claude, strings.ReplaceAll(other, string(filepath.Separator), "-"), "reject.jsonl")
|
|
756
|
-
- writeScopeFile(t, want, `{}`+"\n")
|
|
757
|
-
- writeScopeFile(t, reject, `{}`+"\n")
|
|
758
|
-
+ relatedTranscript := filepath.Join(claude, strings.ReplaceAll(related, string(filepath.Separator), "-"), "related.jsonl")
|
|
759
|
-
+ reject := filepath.Join(claude, strings.ReplaceAll(unrelatedSameName, string(filepath.Separator), "-"), "reject.jsonl")
|
|
760
|
-
+ writeClaudeScopeFixture(t, want, "want")
|
|
761
|
-
+ writeClaudeScopeFixture(t, relatedTranscript, "related")
|
|
762
|
-
+ writeClaudeScopeFixture(t, reject, "reject")
|
|
763
|
-
|
|
764
|
-
- if got := ClaudeFiles(); !slices.Equal(got, []string{want}) {
|
|
765
|
-
- t.Fatalf("ClaudeFiles() = %v, want [%s]", got, want)
|
|
766
|
-
+ got := ClaudeFiles()
|
|
767
|
-
+ slices.Sort(got)
|
|
768
|
-
+ wantFiles := []string{relatedTranscript, want}
|
|
769
|
-
+ slices.Sort(wantFiles)
|
|
770
|
-
+ if !slices.Equal(got, wantFiles) {
|
|
771
|
-
+ t.Fatalf("ClaudeFiles() = %v, want %v", got, wantFiles)
|
|
772
|
-
+ }
|
|
773
|
-
+ sessions := LoadClaude()
|
|
774
|
-
+ if len(sessions) != 2 {
|
|
775
|
-
+ t.Fatalf("LoadClaude() returned %d sessions, want 2", len(sessions))
|
|
776
|
-
+ }
|
|
777
|
-
+ selected = canonicalScopePath(selected)
|
|
778
|
-
+ for _, session := range sessions {
|
|
779
|
-
+ if session.Project != selected {
|
|
780
|
-
+ t.Errorf("Claude session %q project = %q, want selected root %q", session.ID, session.Project, selected)
|
|
781
|
-
+ }
|
|
782
|
-
}
|
|
783
|
-
}
|
|
784
|
-
|
|
785
|
-
func TestCursorTranscriptsHonorProjectRootScopeBeforeParsing(t *testing.T) {
|
|
786
|
-
root := t.TempDir()
|
|
787
|
-
selected := filepath.Join(root, "work", "dosu")
|
|
788
|
-
- other := filepath.Join(root, "work", "other")
|
|
789
|
-
- for _, dir := range []string{selected, other} {
|
|
790
|
-
- if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
791
|
-
- t.Fatal(err)
|
|
792
|
-
- }
|
|
793
|
-
- }
|
|
794
|
-
+ related := filepath.Join(root, "related", "dosu")
|
|
795
|
-
+ unrelatedSameName := filepath.Join(root, "unrelated", "dosu")
|
|
796
|
-
+ initScopeRepository(t, selected, "git@example.com:dosu-ai/dosu.git")
|
|
797
|
-
+ initScopeRepository(t, related, "https://example.com/dosu-ai/dosu.git")
|
|
798
|
-
+ initScopeRepository(t, unrelatedSameName, "https://example.com/someone-else/dosu.git")
|
|
799
|
-
cursor := filepath.Join(root, "cursor")
|
|
800
|
-
t.Setenv("DEJA_CURSOR_CLI_ROOT", cursor)
|
|
801
|
-
+ t.Setenv("DEJA_CURSOR_ROOT", filepath.Join(root, "cursor-ide"))
|
|
802
|
-
t.Setenv("DEJA_PROJECT_ROOTS", selected)
|
|
803
|
-
want := filepath.Join(cursor, "projects", strings.TrimPrefix(strings.ReplaceAll(selected, string(filepath.Separator), "-"), "-"), "agent-transcripts", "want.jsonl")
|
|
804
|
-
- reject := filepath.Join(cursor, "projects", strings.TrimPrefix(strings.ReplaceAll(other, string(filepath.Separator), "-"), "-"), "agent-transcripts", "reject.jsonl")
|
|
805
|
-
- writeScopeFile(t, want, `{}`+"\n")
|
|
806
|
-
- writeScopeFile(t, reject, `{}`+"\n")
|
|
807
|
-
+ relatedTranscript := filepath.Join(cursor, "projects", strings.TrimPrefix(strings.ReplaceAll(related, string(filepath.Separator), "-"), "-"), "agent-transcripts", "related.jsonl")
|
|
808
|
-
+ reject := filepath.Join(cursor, "projects", strings.TrimPrefix(strings.ReplaceAll(unrelatedSameName, string(filepath.Separator), "-"), "-"), "agent-transcripts", "reject.jsonl")
|
|
809
|
-
+ writeCursorScopeFixture(t, want)
|
|
810
|
-
+ writeCursorScopeFixture(t, relatedTranscript)
|
|
811
|
-
+ writeCursorScopeFixture(t, reject)
|
|
812
|
-
|
|
813
|
-
- if got := CursorTranscripts(); !slices.Equal(got, []string{want}) {
|
|
814
|
-
- t.Fatalf("CursorTranscripts() = %v, want [%s]", got, want)
|
|
815
|
-
+ got := CursorTranscripts()
|
|
816
|
-
+ slices.Sort(got)
|
|
817
|
-
+ wantFiles := []string{relatedTranscript, want}
|
|
818
|
-
+ slices.Sort(wantFiles)
|
|
819
|
-
+ if !slices.Equal(got, wantFiles) {
|
|
820
|
-
+ t.Fatalf("CursorTranscripts() = %v, want %v", got, wantFiles)
|
|
821
|
-
}
|
|
822
|
-
+ sessions := LoadCursor()
|
|
823
|
-
+ if len(sessions) != 2 {
|
|
824
|
-
+ t.Fatalf("LoadCursor() returned %d sessions, want 2", len(sessions))
|
|
825
|
-
+ }
|
|
826
|
-
+ selected = canonicalScopePath(selected)
|
|
827
|
-
+ for _, session := range sessions {
|
|
828
|
-
+ if session.Project != selected {
|
|
829
|
-
+ t.Errorf("Cursor session %q project = %q, want selected root %q", session.ID, session.Project, selected)
|
|
830
|
-
+ }
|
|
831
|
-
+ }
|
|
832
|
-
+}
|
|
833
|
-
+
|
|
834
|
-
+func writeClaudeScopeFixture(t *testing.T, path, id string) {
|
|
835
|
-
+ t.Helper()
|
|
836
|
-
+ writeScopeFile(t, path, `{"type":"user","sessionId":"`+id+`","timestamp":"2026-08-20T12:00:00Z","message":{"role":"user","content":"hello"}}`+"\n")
|
|
837
|
-
+}
|
|
838
|
-
+
|
|
839
|
-
+func writeCursorScopeFixture(t *testing.T, path string) {
|
|
840
|
-
+ t.Helper()
|
|
841
|
-
+ writeScopeFile(t, path, `{"role":"user","message":{"content":"hello"}}`+"\n")
|
|
842
|
-
}
|
|
843
|
-
|
|
844
|
-
func writeCodexScopeFixture(t *testing.T, path, cwd, id string) {
|
|
845
|
-
--
|
|
846
|
-
2.50.1 (Apple Git-155)
|
|
847
|
-
|