|
1 | 1 | import type { Hooks } from '@opencode-ai/plugin' |
2 | 2 | import type { Logger } from '../types' |
3 | 3 | import type { GraphService } from '../graph/service' |
| 4 | +import { join, isAbsolute, normalize, resolve, sep } from 'path' |
| 5 | +import { execFileSync, execSync } from 'child_process' |
4 | 6 |
|
5 | 7 | interface GraphToolHookDeps { |
6 | 8 | graphService: GraphService | null |
7 | 9 | logger: Logger |
8 | 10 | cwd: string |
9 | 11 | } |
10 | 12 |
|
| 13 | +/** |
| 14 | + * Map storing pre-command branch snapshots keyed by callID. |
| 15 | + * Only populated for bash commands that are branch-change candidates. |
| 16 | + * Exported for testing purposes. |
| 17 | + */ |
| 18 | +export const pendingBranchSnapshots = new Map<string, { cwd: string; branch: string | null }>() |
| 19 | + |
| 20 | +/** |
| 21 | + * Resolves the effective git working directory for a bash tool call. |
| 22 | + * - Uses args.workdir when present |
| 23 | + * - Falls back to the plugin/project cwd |
| 24 | + * - Normalizes relative paths against the project root |
| 25 | + */ |
| 26 | +export function resolveBashWorkdir(args: unknown, projectCwd: string): string { |
| 27 | + const workdirArg = (args as Record<string, unknown>)?.workdir as string | undefined |
| 28 | + |
| 29 | + if (workdirArg) { |
| 30 | + // Normalize and resolve workdir against project root if relative |
| 31 | + const normalized = normalize(workdirArg) |
| 32 | + return isAbsolute(normalized) ? normalized : join(projectCwd, normalized) |
| 33 | + } |
| 34 | + |
| 35 | + return projectCwd |
| 36 | +} |
| 37 | + |
| 38 | +/** |
| 39 | + * Determines whether a bash command is worth branch tracking. |
| 40 | + * Initial command set includes git branch-changing commands. |
| 41 | + * Excludes file restoration commands with explicit -- separator like `git checkout -- <path>`. |
| 42 | + * For bare `git checkout <arg>` without --, we conservatively track it and let the |
| 43 | + * after-hook compare pre/post branch state to determine if a rescan is needed. |
| 44 | + */ |
| 45 | +export function isBranchChangeCommand(args: unknown): boolean { |
| 46 | + const command = ((args as Record<string, unknown>)?.command as string) || '' |
| 47 | + |
| 48 | + if (!command.trim()) { |
| 49 | + return false |
| 50 | + } |
| 51 | + |
| 52 | + // Normalize command by trimming whitespace |
| 53 | + const trimmed = command.trim() |
| 54 | + |
| 55 | + // git switch always changes branch |
| 56 | + if (/^git\s+switch\s+/.test(trimmed)) { |
| 57 | + return true |
| 58 | + } |
| 59 | + |
| 60 | + // git worktree add changes the worktree context |
| 61 | + if (/^git\s+worktree\s+add\s+/.test(trimmed)) { |
| 62 | + return true |
| 63 | + } |
| 64 | + |
| 65 | + // git checkout can switch branches OR restore files |
| 66 | + // Branch checkout: git checkout <branch> (no -- separator) |
| 67 | + // File restore: git checkout -- <path> or git checkout <rev> -- <path> |
| 68 | + // We conservatively track bare `git checkout <arg>` (without --) because |
| 69 | + // we can't reliably distinguish branches from file paths without running git, |
| 70 | + // and we don't have the working directory context here. |
| 71 | + // The after-hook will compare pre/post branch state to determine the actual behavior. |
| 72 | + if (/^git\s+checkout\s+(.+)$/.test(trimmed)) { |
| 73 | + const checkoutArgs = trimmed.match(/^git\s+checkout\s+(.+)$/)?.[1] || '' |
| 74 | + // If it has -- separator, it's definitely file restoration |
| 75 | + if (checkoutArgs.includes('--')) { |
| 76 | + return false |
| 77 | + } |
| 78 | + // Bare checkout without --: conservatively track it |
| 79 | + // The after-hook will determine if branch actually changed |
| 80 | + return true |
| 81 | + } |
| 82 | + |
| 83 | + return false |
| 84 | +} |
| 85 | + |
| 86 | +/** |
| 87 | + * Extract file paths from git checkout file restoration commands. |
| 88 | + * Handles patterns like: |
| 89 | + * - git checkout -- <path> |
| 90 | + * - git checkout HEAD -- <path> |
| 91 | + * - git checkout <commit> -- <path> |
| 92 | + * - git checkout <path> (unambiguous file path without --) |
| 93 | + * Returns empty array if not a checkout file restore command. |
| 94 | + */ |
| 95 | +export function extractCheckoutPaths(args: unknown, workdir: string): string[] { |
| 96 | + const command = ((args as Record<string, unknown>)?.command as string) || '' |
| 97 | + |
| 98 | + if (!command.trim()) { |
| 99 | + return [] |
| 100 | + } |
| 101 | + |
| 102 | + const trimmed = command.trim() |
| 103 | + |
| 104 | + // Match git checkout with -- separator (file restoration) |
| 105 | + // Patterns: git checkout -- <path>, git checkout HEAD -- <path>, git checkout <commit> -- <path> |
| 106 | + const restoreMatch = trimmed.match(/^git\s+checkout\s+(?:[^\s-]+\s+)?--\s+(.+)$/) |
| 107 | + if (restoreMatch) { |
| 108 | + const pathsStr = restoreMatch[1] |
| 109 | + // Split by whitespace to handle multiple paths |
| 110 | + return pathsStr.split(/\s+/).filter(p => p.length > 0) |
| 111 | + } |
| 112 | + |
| 113 | + // Match git checkout <path> without -- separator |
| 114 | + // This is ambiguous: could be a branch or a file path |
| 115 | + // Use git to reliably check if the argument is a valid branch/ref |
| 116 | + const checkoutMatch = trimmed.match(/^git\s+checkout\s+(.+)$/) |
| 117 | + if (checkoutMatch) { |
| 118 | + const checkoutArgs = checkoutMatch[1] |
| 119 | + // Use git rev-parse to check if it's a valid ref/branch |
| 120 | + // If rev-parse succeeds, it's a branch/ref, not a file path |
| 121 | + try { |
| 122 | + execFileSync('git', ['rev-parse', '--verify', checkoutArgs, '--'], { |
| 123 | + encoding: 'utf-8', |
| 124 | + stdio: ['pipe', 'pipe', 'pipe'], |
| 125 | + cwd: workdir, |
| 126 | + }) |
| 127 | + // It's a valid ref/branch, not a file path |
| 128 | + return [] |
| 129 | + } catch { |
| 130 | + // rev-parse failed, so it's likely a file path -> extract it |
| 131 | + return [checkoutArgs] |
| 132 | + } |
| 133 | + } |
| 134 | + |
| 135 | + return [] |
| 136 | +} |
| 137 | + |
| 138 | +/** |
| 139 | + * Reads the current branch name from the resolved working directory using git. |
| 140 | + * Returns null when the directory is not a repo or the branch cannot be determined. |
| 141 | + */ |
| 142 | +export function getCurrentBranch(workdir: string): string | null { |
| 143 | + try { |
| 144 | + const result = execSync('git rev-parse --abbrev-ref HEAD', { |
| 145 | + cwd: workdir, |
| 146 | + encoding: 'utf-8', |
| 147 | + stdio: ['pipe', 'pipe', 'pipe'], |
| 148 | + }) |
| 149 | + return result.trim() |
| 150 | + } catch { |
| 151 | + // Not a git repo, or git command failed |
| 152 | + return null |
| 153 | + } |
| 154 | +} |
| 155 | + |
11 | 156 | /** |
12 | 157 | * Extract file paths from tool outputs that may have mutated files. |
13 | 158 | * This handles common file-editing tools and bash commands that clearly mutate tracked files. |
14 | 159 | */ |
15 | | -function extractMutatedPaths(tool: string, output: string, args?: unknown): string[] { |
| 160 | +function extractMutatedPaths(tool: string, output: string, args: unknown): string[] { |
16 | 161 | const paths: string[] = [] |
17 | 162 |
|
18 | 163 | // Handle apply_patch tool - explicitly edits files |
@@ -119,31 +264,146 @@ function extractMutatedPaths(tool: string, output: string, args?: unknown): stri |
119 | 264 | } |
120 | 265 |
|
121 | 266 | /** |
122 | | - * Check if a path is within the project root |
| 267 | + * Check if a path is within the project root using proper path containment. |
| 268 | + * Uses realpath-like normalization to avoid prefix collisions. |
| 269 | + * Example: /Users/chris/development/opencode-forge should not contain /Users/chris/development/opencode-forge-backup |
123 | 270 | */ |
124 | 271 | function isPathInProject(absPath: string, cwd: string): boolean { |
125 | | - return absPath.startsWith(cwd) |
| 272 | + const normalizedPath = resolve(absPath) |
| 273 | + const normalizedCwd = resolve(cwd) |
| 274 | + // Ensure cwd ends with separator for proper containment check (cross-platform) |
| 275 | + const cwdWithSep = normalizedCwd.endsWith(sep) |
| 276 | + ? normalizedCwd |
| 277 | + : normalizedCwd + sep |
| 278 | + return normalizedPath === normalizedCwd || normalizedPath.startsWith(cwdWithSep) |
126 | 279 | } |
127 | 280 |
|
128 | | -export function createGraphToolAfterHook(deps: GraphToolHookDeps): Hooks['tool.execute.after'] { |
| 281 | +/** |
| 282 | + * Check if a workdir is within the project root using proper path containment. |
| 283 | + */ |
| 284 | +function isWorkdirInProject(workdir: string, projectCwd: string): boolean { |
| 285 | + return isPathInProject(workdir, projectCwd) |
| 286 | +} |
| 287 | + |
| 288 | +/** |
| 289 | + * Creates a before-hook for graph tool execution that captures pre-command branch state. |
| 290 | + * Only inspects bash tool calls that are branch-change candidates. |
| 291 | + */ |
| 292 | +export function createGraphToolBeforeHook(deps: GraphToolHookDeps): Hooks['tool.execute.before'] { |
129 | 293 | return async ( |
130 | | - input: { tool: string; sessionID: string; callID: string; args?: unknown }, |
131 | | - output: { output?: string }, |
| 294 | + input: { tool: string; sessionID: string; callID: string }, |
| 295 | + output: { args: unknown }, |
132 | 296 | ) => { |
133 | 297 | // No-op if graph service is disabled |
134 | 298 | if (!deps.graphService) { |
135 | 299 | return |
136 | 300 | } |
| 301 | + |
| 302 | + // Only inspect bash tool calls |
| 303 | + if (input.tool !== 'bash') { |
| 304 | + return |
| 305 | + } |
| 306 | + |
| 307 | + // Check if this is a branch-change candidate command |
| 308 | + if (!isBranchChangeCommand(output.args)) { |
| 309 | + return |
| 310 | + } |
| 311 | + |
| 312 | + // Resolve the effective working directory for this bash call |
| 313 | + const workdir = resolveBashWorkdir(output.args, deps.cwd) |
| 314 | + |
| 315 | + // Only track branch changes within the project worktree |
| 316 | + // Skip if workdir is outside the project root (uses proper path containment) |
| 317 | + if (!isWorkdirInProject(workdir, deps.cwd)) { |
| 318 | + deps.logger.debug(`Graph hook: skipping branch tracking for workdir outside project: ${workdir}`) |
| 319 | + return |
| 320 | + } |
| 321 | + |
| 322 | + // Capture the pre-command branch name (may be null if not in a repo) |
| 323 | + const branch = getCurrentBranch(workdir) |
| 324 | + |
| 325 | + // Store the snapshot keyed by callID |
| 326 | + pendingBranchSnapshots.set(input.callID, { |
| 327 | + cwd: workdir, |
| 328 | + branch, |
| 329 | + }) |
| 330 | + |
| 331 | + deps.logger.debug(`Graph hook: captured pre-command branch snapshot for ${input.callID}: branch=${branch ?? 'null'}, cwd=${workdir}`) |
| 332 | + } |
| 333 | +} |
137 | 334 |
|
138 | | - const mutatedPaths = extractMutatedPaths(input.tool, output.output ?? '', input.args) |
| 335 | +export function createGraphToolAfterHook(deps: GraphToolHookDeps): Hooks['tool.execute.after'] { |
| 336 | + return async ( |
| 337 | + input: { tool: string; sessionID: string; callID: string; args: unknown }, |
| 338 | + output: { title: string; output: string; metadata: unknown }, |
| 339 | + ) => { |
| 340 | + // No-op if graph service is disabled |
| 341 | + if (!deps.graphService) { |
| 342 | + return |
| 343 | + } |
| 344 | + |
| 345 | + // Check for pending branch snapshot first |
| 346 | + const snapshot = pendingBranchSnapshots.get(input.callID) |
| 347 | + |
| 348 | + // Always clear the pending snapshot entry for the callID |
| 349 | + pendingBranchSnapshots.delete(input.callID) |
| 350 | + |
| 351 | + if (snapshot) { |
| 352 | + // This was a branch-change candidate - check if branch actually changed |
| 353 | + const nextBranch = getCurrentBranch(snapshot.cwd) |
| 354 | + |
| 355 | + if (snapshot.branch !== nextBranch) { |
| 356 | + // Branch changed - trigger full rescan |
| 357 | + deps.logger.log(`Graph hook: branch switch detected (${snapshot.branch ?? 'null'} -> ${nextBranch ?? 'null'}), triggering full graph rescan in background`) |
| 358 | + void deps.graphService.scan().catch((err) => { |
| 359 | + deps.logger.error('Graph hook: background graph rescan failed', err) |
| 360 | + }) |
| 361 | + return |
| 362 | + } |
| 363 | + |
| 364 | + // Branch did not change - check for file restoration commands first, then fall through to per-file mutation path |
| 365 | + deps.logger.debug(`Graph hook: no branch change for ${input.callID}, checking file mutations`) |
| 366 | + |
| 367 | + // Extract file paths from git checkout file restoration commands |
| 368 | + const workdir = resolveBashWorkdir(input.args, deps.cwd) |
| 369 | + const checkoutPaths = extractCheckoutPaths(input.args, workdir) |
| 370 | + if (checkoutPaths.length > 0) { |
| 371 | + deps.logger.debug(`Graph hook: detected git checkout file restoration, enqueuing ${checkoutPaths.length} file(s)`) |
| 372 | + for (const path of checkoutPaths) { |
| 373 | + const absPath = path.startsWith('/') ? path : `${snapshot.cwd}/${path}` |
| 374 | + if (isPathInProject(absPath, deps.cwd)) { |
| 375 | + deps.graphService.onFileChanged(absPath) |
| 376 | + } |
| 377 | + } |
| 378 | + return |
| 379 | + } |
| 380 | + } |
| 381 | + |
| 382 | + // Extract per-file mutations (existing behavior) |
| 383 | + const mutatedPaths: string[] = extractMutatedPaths(input.tool, output.output ?? '', input.args) |
| 384 | + |
| 385 | + // Also check for git checkout file restoration commands (these don't have branch snapshots) |
| 386 | + const workdir = resolveBashWorkdir(input.args, deps.cwd) |
| 387 | + const checkoutPaths = extractCheckoutPaths(input.args, workdir) |
| 388 | + if (checkoutPaths.length > 0) { |
| 389 | + for (const path of checkoutPaths) { |
| 390 | + const absPath = path.startsWith('/') ? path : `${workdir}/${path}` |
| 391 | + if (isPathInProject(absPath, deps.cwd)) { |
| 392 | + mutatedPaths.push(absPath) |
| 393 | + } |
| 394 | + } |
| 395 | + } |
139 | 396 |
|
140 | 397 | if (mutatedPaths.length === 0) { |
141 | 398 | return |
142 | 399 | } |
143 | 400 |
|
| 401 | + // Resolve the effective working directory for bash calls to correctly resolve relative paths |
| 402 | + const bashWorkdir = input.tool === 'bash' ? workdir : deps.cwd |
| 403 | + |
144 | 404 | for (const path of mutatedPaths) { |
145 | | - // Resolve to absolute path |
146 | | - const absPath = path.startsWith('/') ? path : `${deps.cwd}/${path}` |
| 405 | + // Checkout paths are already absolute, others need resolution |
| 406 | + const absPath = path.startsWith('/') ? path : resolve(bashWorkdir, path) |
147 | 407 |
|
148 | 408 | // Only enqueue if within project |
149 | 409 | if (!isPathInProject(absPath, deps.cwd)) { |
|
0 commit comments