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/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
- config = resolveConfig(activeRawSettings, projectConfig);
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
- config = resolveConfig(activeRawSettings, projectConfig);
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
 
@@ -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 normalized = normalizeProjectPath(projectDir);
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 = state.projects[normalized];
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 normalized = normalizeProjectPath(projectDir);
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[normalized] = {
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 (shard.frames && shard.frames.length > 0) {
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;