omp-plugin-duplicate-detector 0.1.0 → 0.2.0
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 +5 -17
- package/dist/detector-worker.js +418 -272
- package/package.json +1 -1
- package/src/coordinator.ts +16 -4
- package/src/detector-worker.ts +63 -24
- package/src/disk-cache.ts +365 -314
- package/src/index.ts +134 -7
- package/src/project-state.ts +10 -4
- package/src/repo-context.ts +158 -0
- package/src/source-aware-index.ts +14 -1
package/src/index.ts
CHANGED
|
@@ -13,7 +13,9 @@ import { DuplicateLedger } from "./duplicate-ledger";
|
|
|
13
13
|
import {
|
|
14
14
|
type BaselineStatus,
|
|
15
15
|
createIgnoreFilter,
|
|
16
|
+
execGit,
|
|
16
17
|
isGeneratedContent,
|
|
18
|
+
isInsideGitWorkTree,
|
|
17
19
|
MAX_INDEXED_FILES,
|
|
18
20
|
} from "./jscpd-engine";
|
|
19
21
|
import { isProjectEnabled, setProjectEnabled } from "./project-state";
|
|
@@ -335,7 +337,105 @@ export default function duplicateDetectorExtension(pi: ExtensionAPI): void {
|
|
|
335
337
|
const ledger = new DuplicateLedger();
|
|
336
338
|
const fileRevisions = new Map<string, number>();
|
|
337
339
|
const coordinator = new DuplicateDetectorCoordinator();
|
|
340
|
+
let lastKnownHead: string | null = null;
|
|
338
341
|
let workerFailureNotified = false;
|
|
342
|
+
|
|
343
|
+
const reconcileParentGitState = async (cwd: string): Promise<void> => {
|
|
344
|
+
if (!isEnabledForProject) return;
|
|
345
|
+
try {
|
|
346
|
+
const isGit = await isInsideGitWorkTree(cwd);
|
|
347
|
+
if (!isGit) return;
|
|
348
|
+
|
|
349
|
+
const { stdout: currentHeadRaw } = await execGit(
|
|
350
|
+
["rev-parse", "HEAD"],
|
|
351
|
+
cwd,
|
|
352
|
+
).catch(() => ({ stdout: "" }));
|
|
353
|
+
const currentHead = currentHeadRaw.trim();
|
|
354
|
+
|
|
355
|
+
const modifiedFiles: string[] = [];
|
|
356
|
+
let reconciliationSucceeded = true;
|
|
357
|
+
|
|
358
|
+
if (lastKnownHead && currentHead && lastKnownHead !== currentHead) {
|
|
359
|
+
try {
|
|
360
|
+
const { stdout: diffOut } = await execGit(
|
|
361
|
+
[
|
|
362
|
+
"diff",
|
|
363
|
+
"--name-status",
|
|
364
|
+
"-z",
|
|
365
|
+
lastKnownHead,
|
|
366
|
+
currentHead,
|
|
367
|
+
"--",
|
|
368
|
+
".",
|
|
369
|
+
],
|
|
370
|
+
cwd,
|
|
371
|
+
);
|
|
372
|
+
|
|
373
|
+
const tokens = diffOut.split("\0");
|
|
374
|
+
let t = 0;
|
|
375
|
+
while (t < tokens.length) {
|
|
376
|
+
const status = tokens[t]?.trim();
|
|
377
|
+
t++;
|
|
378
|
+
if (!status) continue;
|
|
379
|
+
if (status.startsWith("R") || status.startsWith("C")) {
|
|
380
|
+
const oldPath = tokens[t]?.trim();
|
|
381
|
+
t++;
|
|
382
|
+
const newPath = tokens[t]?.trim();
|
|
383
|
+
t++;
|
|
384
|
+
if (oldPath) modifiedFiles.push(path.resolve(cwd, oldPath));
|
|
385
|
+
if (newPath) modifiedFiles.push(path.resolve(cwd, newPath));
|
|
386
|
+
} else {
|
|
387
|
+
const filePath = tokens[t]?.trim();
|
|
388
|
+
t++;
|
|
389
|
+
if (filePath) modifiedFiles.push(path.resolve(cwd, filePath));
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
} catch {
|
|
393
|
+
reconciliationSucceeded = false;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
const { stdout: statusOut } = await execGit(
|
|
397
|
+
["status", "--porcelain", "-z", "--untracked-files=no", "--", "."],
|
|
398
|
+
cwd,
|
|
399
|
+
).catch(() => ({ stdout: "" }));
|
|
400
|
+
|
|
401
|
+
const statusEntries = statusOut.split("\0");
|
|
402
|
+
let i = 0;
|
|
403
|
+
while (i < statusEntries.length) {
|
|
404
|
+
const entry = statusEntries[i];
|
|
405
|
+
i++;
|
|
406
|
+
if (!entry || entry.length < 4) continue;
|
|
407
|
+
const statusCode = entry.slice(0, 2);
|
|
408
|
+
const relPath = entry.slice(3).trim();
|
|
409
|
+
if (statusCode.includes("R") && i < statusEntries.length) {
|
|
410
|
+
const oldRelPath = statusEntries[i]?.trim();
|
|
411
|
+
i++;
|
|
412
|
+
if (oldRelPath) {
|
|
413
|
+
modifiedFiles.push(path.resolve(cwd, oldRelPath));
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
if (relPath) {
|
|
417
|
+
modifiedFiles.push(path.resolve(cwd, relPath));
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
if (modifiedFiles.length > 0) {
|
|
421
|
+
const uniqueFiles = [...new Set(modifiedFiles)];
|
|
422
|
+
try {
|
|
423
|
+
await coordinator.reconcile(
|
|
424
|
+
uniqueFiles.map((filePath) => ({ filePath })),
|
|
425
|
+
);
|
|
426
|
+
} catch {
|
|
427
|
+
reconciliationSucceeded = false;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// Only advance the watermark when reconciliation succeeded
|
|
432
|
+
if (reconciliationSucceeded && currentHead) {
|
|
433
|
+
lastKnownHead = currentHead;
|
|
434
|
+
}
|
|
435
|
+
} catch {
|
|
436
|
+
// Fail open
|
|
437
|
+
}
|
|
438
|
+
};
|
|
339
439
|
const notifyWorkerFailure = (error: unknown): void => {
|
|
340
440
|
if (workerFailureNotified || !lastCtx) return;
|
|
341
441
|
workerFailureNotified = true;
|
|
@@ -473,17 +573,26 @@ export default function duplicateDetectorExtension(pi: ExtensionAPI): void {
|
|
|
473
573
|
ledger.clear();
|
|
474
574
|
fileRevisions.clear();
|
|
475
575
|
workerFailureNotified = false;
|
|
576
|
+
lastKnownHead = null;
|
|
476
577
|
if (ctx?.cwd) {
|
|
477
578
|
currentCwd = ctx.cwd;
|
|
579
|
+
execGit(["rev-parse", "HEAD"], ctx.cwd)
|
|
580
|
+
.then(({ stdout }) => {
|
|
581
|
+
lastKnownHead = stdout.trim() || null;
|
|
582
|
+
})
|
|
583
|
+
.catch(() => {
|
|
584
|
+
lastKnownHead = null;
|
|
585
|
+
});
|
|
478
586
|
}
|
|
479
587
|
lastCtx = ctx as ExtensionContext;
|
|
480
|
-
|
|
481
588
|
activeRawSettings = extractSettingsObject(event, ctx);
|
|
589
|
+
config = resolveConfig(activeRawSettings, null);
|
|
482
590
|
const projectConfig = ctx?.cwd
|
|
483
591
|
? await findProjectJscpdConfig(ctx.cwd)
|
|
484
592
|
: null;
|
|
485
|
-
|
|
486
|
-
|
|
593
|
+
if (projectConfig) {
|
|
594
|
+
config = resolveConfig(activeRawSettings, projectConfig);
|
|
595
|
+
}
|
|
487
596
|
if (ctx?.cwd) {
|
|
488
597
|
isEnabledForProject = await isProjectEnabled(ctx.cwd);
|
|
489
598
|
if (!isEnabledForProject) {
|
|
@@ -517,6 +626,13 @@ export default function duplicateDetectorExtension(pi: ExtensionAPI): void {
|
|
|
517
626
|
});
|
|
518
627
|
}
|
|
519
628
|
|
|
629
|
+
// Trigger parent git reconciliation on turn start
|
|
630
|
+
pi.on("before_agent_start", async (_event, ctx) => {
|
|
631
|
+
if (ctx?.cwd) {
|
|
632
|
+
await reconcileParentGitState(ctx.cwd);
|
|
633
|
+
}
|
|
634
|
+
});
|
|
635
|
+
|
|
520
636
|
// Initialize repository index in background on session start (non-blocking)
|
|
521
637
|
pi.on("session_start", async (event, ctx) => {
|
|
522
638
|
currentCwd = ctx.cwd;
|
|
@@ -524,14 +640,23 @@ export default function duplicateDetectorExtension(pi: ExtensionAPI): void {
|
|
|
524
640
|
fileRevisions.clear();
|
|
525
641
|
workerFailureNotified = false;
|
|
526
642
|
|
|
643
|
+
execGit(["rev-parse", "HEAD"], ctx.cwd)
|
|
644
|
+
.then(({ stdout }) => {
|
|
645
|
+
lastKnownHead = stdout.trim() || null;
|
|
646
|
+
})
|
|
647
|
+
.catch(() => {
|
|
648
|
+
lastKnownHead = null;
|
|
649
|
+
});
|
|
527
650
|
pi.logger.debug("Duplicate detector initializing workspace index", {
|
|
528
651
|
cwd: ctx.cwd,
|
|
529
652
|
});
|
|
530
653
|
|
|
531
654
|
activeRawSettings = extractSettingsObject(event, ctx);
|
|
655
|
+
config = resolveConfig(activeRawSettings, null);
|
|
532
656
|
const projectConfig = await findProjectJscpdConfig(ctx.cwd);
|
|
533
|
-
|
|
534
|
-
|
|
657
|
+
if (projectConfig) {
|
|
658
|
+
config = resolveConfig(activeRawSettings, projectConfig);
|
|
659
|
+
}
|
|
535
660
|
if (config.configSource) {
|
|
536
661
|
pi.logger.info("Duplicate detector loaded project configuration", {
|
|
537
662
|
source: config.configSource,
|
|
@@ -568,12 +693,14 @@ export default function duplicateDetectorExtension(pi: ExtensionAPI): void {
|
|
|
568
693
|
if (!isEnabledForProject) return;
|
|
569
694
|
if (!config.checkOnMutation) return;
|
|
570
695
|
if (config.reminderMode === "none") return;
|
|
696
|
+
if (event.toolName === "task" || event.toolName === "bash") {
|
|
697
|
+
await reconcileParentGitState(ctx.cwd);
|
|
698
|
+
return;
|
|
699
|
+
}
|
|
571
700
|
if (event.toolName !== "write" && event.toolName !== "edit") return;
|
|
572
|
-
|
|
573
701
|
const input = event.input as { path?: string };
|
|
574
702
|
const rawPath = input?.path;
|
|
575
703
|
if (!rawPath || typeof rawPath !== "string") return;
|
|
576
|
-
|
|
577
704
|
// Skip internal protocol URLs (e.g. xd://, local://)
|
|
578
705
|
if (rawPath.includes("://")) return;
|
|
579
706
|
|
package/src/project-state.ts
CHANGED
|
@@ -2,10 +2,12 @@ import * as fsSync from "node:fs";
|
|
|
2
2
|
import * as fs from "node:fs/promises";
|
|
3
3
|
import * as path from "node:path";
|
|
4
4
|
import { getDefaultCacheDir } from "./disk-cache";
|
|
5
|
+
import { resolveRepositoryContext } from "./repo-context";
|
|
5
6
|
|
|
6
7
|
interface ProjectStateEntry {
|
|
7
8
|
enabled: boolean;
|
|
8
9
|
updatedAt?: string;
|
|
10
|
+
displayPath?: string;
|
|
9
11
|
}
|
|
10
12
|
|
|
11
13
|
export interface ProjectsStateFile {
|
|
@@ -102,9 +104,11 @@ export async function isProjectEnabled(
|
|
|
102
104
|
projectDir: string,
|
|
103
105
|
customCacheDir?: string,
|
|
104
106
|
): Promise<boolean> {
|
|
105
|
-
const
|
|
107
|
+
const ctx = await resolveRepositoryContext(projectDir).catch(() => null);
|
|
108
|
+
const key = ctx?.repositoryKey ?? normalizeProjectPath(projectDir);
|
|
106
109
|
const state = await loadProjectsState(customCacheDir);
|
|
107
|
-
const entry =
|
|
110
|
+
const entry =
|
|
111
|
+
state.projects[key] ?? state.projects[normalizeProjectPath(projectDir)];
|
|
108
112
|
if (entry && typeof entry.enabled === "boolean") {
|
|
109
113
|
return entry.enabled;
|
|
110
114
|
}
|
|
@@ -119,11 +123,13 @@ export async function setProjectEnabled(
|
|
|
119
123
|
enabled: boolean,
|
|
120
124
|
customCacheDir?: string,
|
|
121
125
|
): Promise<void> {
|
|
122
|
-
const
|
|
126
|
+
const ctx = await resolveRepositoryContext(projectDir).catch(() => null);
|
|
127
|
+
const key = ctx?.repositoryKey ?? normalizeProjectPath(projectDir);
|
|
123
128
|
const state = await loadProjectsState(customCacheDir);
|
|
124
|
-
state.projects[
|
|
129
|
+
state.projects[key] = {
|
|
125
130
|
enabled,
|
|
126
131
|
updatedAt: new Date().toISOString(),
|
|
132
|
+
displayPath: normalizeProjectPath(projectDir),
|
|
127
133
|
};
|
|
128
134
|
await saveProjectsState(state, customCacheDir);
|
|
129
135
|
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import * as crypto from "node:crypto";
|
|
2
|
+
import * as fsSync from "node:fs";
|
|
3
|
+
import * as fs from "node:fs/promises";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import { execGit } from "./jscpd-engine";
|
|
6
|
+
|
|
7
|
+
export interface RepositoryContext {
|
|
8
|
+
/** Canonical absolute workspace root (used for file enumeration and relative path calculation) */
|
|
9
|
+
workspaceRoot: string;
|
|
10
|
+
/** Whether the workspace is inside a Git repository */
|
|
11
|
+
isGit: boolean;
|
|
12
|
+
/** Canonical absolute .git directory */
|
|
13
|
+
gitDir?: string;
|
|
14
|
+
/** Canonical absolute common .git directory */
|
|
15
|
+
commonGitDir?: string;
|
|
16
|
+
/** Canonical absolute object directory defining the repository storage identity */
|
|
17
|
+
repositoryObjectDir: string;
|
|
18
|
+
/** 16-hex deterministic hash of the repository identity (used for cache DB & project state keying) */
|
|
19
|
+
repositoryKey: string;
|
|
20
|
+
/** Whether this workspace is an Oh My Pi isolated CoW worktree */
|
|
21
|
+
isOmpIsolation: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Normalizes a path by resolving symlinks if the target exists, falling back to path.resolve.
|
|
26
|
+
*/
|
|
27
|
+
export function canonicalizePath(targetPath: string): string {
|
|
28
|
+
const resolved = path.resolve(targetPath);
|
|
29
|
+
try {
|
|
30
|
+
if (fsSync.existsSync(resolved)) {
|
|
31
|
+
return fsSync.realpathSync(resolved);
|
|
32
|
+
}
|
|
33
|
+
} catch {
|
|
34
|
+
// Fall back to resolved
|
|
35
|
+
}
|
|
36
|
+
return resolved;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Checks if a path is located inside an Oh My Pi worktree hierarchy (~/.omp/wt/...).
|
|
41
|
+
*/
|
|
42
|
+
export function isOmpWorktreePath(targetPath: string): boolean {
|
|
43
|
+
const normalized = targetPath.replace(/\\/g, "/");
|
|
44
|
+
return (
|
|
45
|
+
normalized.includes("/.omp/wt/") || normalized.includes("/.omp/worktrees/")
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Resolves repository context for a workspace directory.
|
|
51
|
+
* Separates workspaceRoot (file scanning and relative paths) from repositoryIdentity (cache DB keying).
|
|
52
|
+
*/
|
|
53
|
+
export async function resolveRepositoryContext(
|
|
54
|
+
cwd: string,
|
|
55
|
+
signal?: AbortSignal,
|
|
56
|
+
): Promise<RepositoryContext> {
|
|
57
|
+
const canonicalCwd = canonicalizePath(cwd);
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
// Query Git for toplevel, git-dir, and git-common-dir in a single batch
|
|
61
|
+
const { stdout } = await execGit(
|
|
62
|
+
[
|
|
63
|
+
"rev-parse",
|
|
64
|
+
"--path-format=absolute",
|
|
65
|
+
"--show-toplevel",
|
|
66
|
+
"--git-dir",
|
|
67
|
+
"--git-common-dir",
|
|
68
|
+
],
|
|
69
|
+
canonicalCwd,
|
|
70
|
+
{ signal },
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
const lines = stdout
|
|
74
|
+
.split("\n")
|
|
75
|
+
.map((l) => l.trim())
|
|
76
|
+
.filter(Boolean);
|
|
77
|
+
|
|
78
|
+
const resolveEntry = (val?: string): string => {
|
|
79
|
+
if (!val) return "";
|
|
80
|
+
return path.isAbsolute(val) ? val : path.resolve(canonicalCwd, val);
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const workspaceRoot = canonicalizePath(resolveEntry(lines[0]));
|
|
84
|
+
const gitDir = canonicalizePath(
|
|
85
|
+
resolveEntry(lines[1] || path.join(workspaceRoot, ".git")),
|
|
86
|
+
);
|
|
87
|
+
const commonGitDir = canonicalizePath(
|
|
88
|
+
resolveEntry(lines[2] || lines[1] || path.join(workspaceRoot, ".git")),
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
let repositoryObjectDir = path.join(commonGitDir, "objects");
|
|
92
|
+
const isOmpIsolation =
|
|
93
|
+
isOmpWorktreePath(workspaceRoot) || isOmpWorktreePath(canonicalCwd);
|
|
94
|
+
|
|
95
|
+
// Check for detached CoW worktrees (e.g. oh-my-pi pi-iso detachGitDir)
|
|
96
|
+
// Only replace object store identity when positively identified as an Oh My Pi CoW worktree
|
|
97
|
+
if (isOmpIsolation && gitDir === commonGitDir) {
|
|
98
|
+
const alternatesFile = path.join(gitDir, "objects", "info", "alternates");
|
|
99
|
+
try {
|
|
100
|
+
if (fsSync.existsSync(alternatesFile)) {
|
|
101
|
+
const content = await fs.readFile(alternatesFile, "utf-8");
|
|
102
|
+
const altLines = content
|
|
103
|
+
.split("\n")
|
|
104
|
+
.map((l) => l.trim())
|
|
105
|
+
.filter((l) => l && !l.startsWith("#"));
|
|
106
|
+
|
|
107
|
+
for (const line of altLines) {
|
|
108
|
+
// Git alternates paths are relative to the object directory ($GIT_DIR/objects)
|
|
109
|
+
const resolvedAlt = path.isAbsolute(line)
|
|
110
|
+
? line
|
|
111
|
+
: path.resolve(path.join(gitDir, "objects"), line);
|
|
112
|
+
|
|
113
|
+
const canonicalAlt = canonicalizePath(resolvedAlt);
|
|
114
|
+
if (fsSync.existsSync(canonicalAlt)) {
|
|
115
|
+
repositoryObjectDir = canonicalAlt;
|
|
116
|
+
break;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
} catch {
|
|
121
|
+
// Fail open to standard commonGitDir objects
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
repositoryObjectDir = canonicalizePath(repositoryObjectDir);
|
|
126
|
+
const repositoryKey = crypto
|
|
127
|
+
.createHash("sha256")
|
|
128
|
+
.update(`git-object-dir\0${repositoryObjectDir}`)
|
|
129
|
+
.digest("hex")
|
|
130
|
+
.slice(0, 16);
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
workspaceRoot,
|
|
134
|
+
isGit: true,
|
|
135
|
+
gitDir,
|
|
136
|
+
commonGitDir,
|
|
137
|
+
repositoryObjectDir,
|
|
138
|
+
repositoryKey,
|
|
139
|
+
isOmpIsolation,
|
|
140
|
+
};
|
|
141
|
+
} catch {
|
|
142
|
+
// Non-Git directory fallback
|
|
143
|
+
const repositoryObjectDir = path.join(canonicalCwd, ".non-git");
|
|
144
|
+
const repositoryKey = crypto
|
|
145
|
+
.createHash("sha256")
|
|
146
|
+
.update(`directory\0${canonicalCwd}`)
|
|
147
|
+
.digest("hex")
|
|
148
|
+
.slice(0, 16);
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
workspaceRoot: canonicalCwd,
|
|
152
|
+
isGit: false,
|
|
153
|
+
repositoryObjectDir,
|
|
154
|
+
repositoryKey,
|
|
155
|
+
isOmpIsolation: false,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
}
|
|
@@ -478,7 +478,20 @@ export class SourceAwareCloneIndex {
|
|
|
478
478
|
let normalizedFrames: CompactSourceFrame[];
|
|
479
479
|
const shardTokens = shard.tokens;
|
|
480
480
|
|
|
481
|
-
if (
|
|
481
|
+
if (
|
|
482
|
+
shardTokens &&
|
|
483
|
+
shardTokens.length > 0 &&
|
|
484
|
+
(shard.minTokens !== this.#minTokens ||
|
|
485
|
+
!shard.frames ||
|
|
486
|
+
shard.frames.length === 0)
|
|
487
|
+
) {
|
|
488
|
+
normalizedFrames = reconstructFramesFromTokens(
|
|
489
|
+
shardTokens,
|
|
490
|
+
sourceId,
|
|
491
|
+
this.#minTokens,
|
|
492
|
+
this.#hashFunction,
|
|
493
|
+
);
|
|
494
|
+
} else if (shard.frames && shard.frames.length > 0) {
|
|
482
495
|
normalizedFrames = shard.frames.map((f) => {
|
|
483
496
|
if (f instanceof CompactSourceFrame && f.sourceId === sourceId) {
|
|
484
497
|
return f;
|