| ... | @@ -1,6 +1,7 @@ | ... | @@ -1,6 +1,7 @@ |
| 1 | import { SourceControlProviderKind } from "@t3tools/contracts"; | 1 | import { SourceControlProviderKind } from "@t3tools/contracts"; |
| 2 | import * as Arr from "effect/Array"; | 2 | import * as Arr from "effect/Array"; |
| 3 | import * as Cache from "effect/Cache"; | 3 | import * as Cache from "effect/Cache"; |
| | 4 | import * as Clock from "effect/Clock"; |
| 4 | import * as Context from "effect/Context"; | 5 | import * as Context from "effect/Context"; |
| 5 | import * as Crypto from "effect/Crypto"; | 6 | import * as Crypto from "effect/Crypto"; |
| 6 | import * as ByteSize from "effect/ByteSize"; | 7 | import * as ByteSize from "effect/ByteSize"; |
| ... | @@ -131,10 +132,14 @@ export class GitManager extends Context.Service< | ... | @@ -131,10 +132,14 @@ export class GitManager extends Context.Service< |
| 131 | input: VcsStatusInput, | 132 | input: VcsStatusInput, |
| 132 | options?: GitRemoteStatusOptions, | 133 | options?: GitRemoteStatusOptions, |
| 133 | ) => Effect.Effect<VcsStatusRemoteResult | null, GitManagerServiceError>; | 134 | ) => Effect.Effect<VcsStatusRemoteResult | null, GitManagerServiceError>; |
| 134 | /** Resolve the PR for a saved branch without changing the current checkout. */ | 135 | /** |
| | 136 | * Resolve the PR for a saved branch without changing the current checkout. |
| | 137 | * `refresh` asks the host again. `freshGitState` rereads the branch's git |
| | 138 | * state without forcing a host refresh; use it before persisting a result. |
| | 139 | */ |
| 135 | readonly branchPullRequest: ( | 140 | readonly branchPullRequest: ( |
| 136 | input: { readonly cwd: string; readonly branch: string }, | 141 | input: { readonly cwd: string; readonly branch: string }, |
| 137 | options?: { readonly refresh?: boolean }, | 142 | options?: { readonly refresh?: boolean; readonly freshGitState?: boolean }, |
| 138 | ) => Effect.Effect<GitBranchPullRequest | null, GitManagerServiceError>; | 143 | ) => Effect.Effect<GitBranchPullRequest | null, GitManagerServiceError>; |
| 139 | readonly invalidateLocalStatus: (cwd: string) => Effect.Effect<void, never>; | 144 | readonly invalidateLocalStatus: (cwd: string) => Effect.Effect<void, never>; |
| 140 | readonly invalidateRemoteStatus: (cwd: string) => Effect.Effect<void, never>; | 145 | readonly invalidateRemoteStatus: (cwd: string) => Effect.Effect<void, never>; |
| ... | @@ -183,6 +188,10 @@ const PR_LOOKUP_NO_OPEN_PR_CACHE_TTL = Duration.minutes(5); | ... | @@ -183,6 +188,10 @@ const PR_LOOKUP_NO_OPEN_PR_CACHE_TTL = Duration.minutes(5); |
| 183 | const PR_LOOKUP_FAILURE_BASE_TTL = Duration.seconds(20); | 188 | const PR_LOOKUP_FAILURE_BASE_TTL = Duration.seconds(20); |
| 184 | const PR_LOOKUP_FAILURE_MAX_TTL = Duration.minutes(15); | 189 | const PR_LOOKUP_FAILURE_MAX_TTL = Duration.minutes(15); |
| 185 | const PR_LOOKUP_CACHE_CAPACITY = 2_048; | 190 | const PR_LOOKUP_CACHE_CAPACITY = 2_048; |
| | 191 | // How long branchPullRequest reuses the git state around a cached PR answer. |
| | 192 | // Matches PR_LOOKUP_NO_OPEN_PR_CACHE_TTL, the delay already accepted for a |
| | 193 | // change made outside the app. |
| | 194 | const PR_LOOKUP_GIT_STATE_TTL = Duration.minutes(5); |
| 186 | const isSourceControlProviderError = Schema.is(SourceControlProviderError); | 195 | const isSourceControlProviderError = Schema.is(SourceControlProviderError); |
| 187 | | 196 | |
| 188 | /** | 197 | /** |
| ... | @@ -1491,20 +1500,6 @@ export const make = Effect.gen(function* () { | ... | @@ -1491,20 +1500,6 @@ export const make = Effect.gen(function* () { |
| 1491 | { concurrency: "unbounded" }, | 1500 | { concurrency: "unbounded" }, |
| 1492 | ); | 1501 | ); |
| 1493 | | 1502 | |
| 1494 | const resolvePrLookupRepositoryIdentity = Effect.fn("resolvePrLookupRepositoryIdentity")( | | |
| 1495 | function* (cwd: string, branch: string, remoteNameOverride?: string) { | | |
| 1496 | const remoteName = | | |
| 1497 | remoteNameOverride ?? (yield* readConfigValueNullable(cwd, `branch.${branch}.remote`)); | | |
| 1498 | const [headRemote, targetRemote] = yield* resolveHeadAndOriginContexts(cwd, remoteName); | | |
| 1499 | return { | | |
| 1500 | remoteName, | | |
| 1501 | headRemoteUrlKey: | | |
| 1502 | headRemote.remoteUrlKey ?? (remoteName === null ? targetRemote.remoteUrlKey : null), | | |
| 1503 | targetRemoteUrlKey: targetRemote.remoteUrlKey, | | |
| 1504 | }; | | |
| 1505 | }, | | |
| 1506 | ); | | |
| 1507 | | | |
| 1508 | const resolveBranchHeadContext = Effect.fn("resolveBranchHeadContext")(function* ( | 1503 | const resolveBranchHeadContext = Effect.fn("resolveBranchHeadContext")(function* ( |
| 1509 | cwd: string, | 1504 | cwd: string, |
| 1510 | details: { branch: string; upstreamRef: string | null; remoteName?: string }, | 1505 | details: { branch: string; upstreamRef: string | null; remoteName?: string }, |
| ... | @@ -2357,32 +2352,134 @@ export const make = Effect.gen(function* () { | ... | @@ -2357,32 +2352,134 @@ export const make = Effect.gen(function* () { |
| 2357 | }); | 2352 | }); |
| 2358 | return mergeGitStatusParts(local, remote); | 2353 | return mergeGitStatusParts(local, remote); |
| 2359 | }); | 2354 | }); |
| 2360 | const branchPullRequest: GitManager["Service"]["branchPullRequest"] = Effect.fn( | 2355 | // Discovery and the settlement sweep call branchPullRequest for every thread |
| 2361 | "branchPullRequest", | 2356 | // each minute, and most calls find the PR answer already cached. The git |
| 2362 | )(function* ({ cwd, branch }, options) { | 2357 | // state around that answer (remotes, the branch's upstream, the remote HEAD, |
| 2363 | const cacheCwd = yield* normalizeStatusCacheKey(cwd); | 2358 | // remote URLs) is remembered per checkout for PR_LOOKUP_GIT_STATE_TTL, so |
| 2364 | const remotes = yield* gitCore.execute({ | 2359 | // those calls start no git process. T3's own git actions (invalidateStatus) |
| 2365 | operation: "GitManager.branchPullRequest.remotes", | 2360 | // and `refresh` read it again, and a change made outside T3 shows within the |
| 2366 | cwd: cacheCwd, | 2361 | // TTL. A merged or closed answer can settle a thread or remove its worktree, |
| 2367 | args: ["remote"], | 2362 | // so branchPullRequest confirms one against fresh reads, and discovery asks |
| | 2363 | // for fresh reads (`freshGitState`) before it persists an answer. |
| | 2364 | const prLookupGitStateByKey = new Map< |
| | 2365 | string, |
| | 2366 | { readonly readAtMs: number; readonly value: string | null } |
| | 2367 | >(); |
| | 2368 | const rememberPrLookupGitState = <E>( |
| | 2369 | cwd: string, |
| | 2370 | key: string, |
| | 2371 | fresh: boolean, |
| | 2372 | read: Effect.Effect<string | null, E>, |
| | 2373 | ) => |
| | 2374 | Effect.gen(function* () { |
| | 2375 | const entryKey = `${cwd}\u0000${key}`; |
| | 2376 | const readAtMs = yield* Clock.currentTimeMillis; |
| | 2377 | const remembered = prLookupGitStateByKey.get(entryKey); |
| | 2378 | if ( |
| | 2379 | !fresh && |
| | 2380 | remembered !== undefined && |
| | 2381 | readAtMs - remembered.readAtMs < Duration.toMillis(PR_LOOKUP_GIT_STATE_TTL) |
| | 2382 | ) { |
| | 2383 | return remembered.value; |
| | 2384 | } |
| | 2385 | // invalidateStatus bumps the epoch. A read that started before a T3 git |
| | 2386 | // action, or before a newer read finished, must not replace their state. |
| | 2387 | const epoch = prLookupEpoch(cwd); |
| | 2388 | const value = yield* read; |
| | 2389 | const latest = prLookupGitStateByKey.get(entryKey); |
| | 2390 | if (prLookupEpoch(cwd) !== epoch || (latest !== undefined && latest.readAtMs > readAtMs)) { |
| | 2391 | return value; |
| | 2392 | } |
| | 2393 | prLookupGitStateByKey.delete(entryKey); |
| | 2394 | if (prLookupGitStateByKey.size >= PR_LOOKUP_CACHE_CAPACITY) { |
| | 2395 | const oldestKey = prLookupGitStateByKey.keys().next().value; |
| | 2396 | if (oldestKey !== undefined) prLookupGitStateByKey.delete(oldestKey); |
| | 2397 | } |
| | 2398 | prLookupGitStateByKey.set(entryKey, { readAtMs, value }); |
| | 2399 | return value; |
| 2368 | }); | 2400 | }); |
| 2369 | const remoteNames = remotes.stdout | 2401 | const forgetPrLookupGitState = (cwd: string) => |
| | 2402 | normalizeStatusCacheKey(cwd).pipe( |
| | 2403 | Effect.map((cacheCwd) => { |
| | 2404 | const prefix = `${cacheCwd}\u0000`; |
| | 2405 | for (const key of prLookupGitStateByKey.keys()) { |
| | 2406 | if (key.startsWith(prefix)) prLookupGitStateByKey.delete(key); |
| | 2407 | } |
| | 2408 | }), |
| | 2409 | ); |
| | 2410 | const resolvePrLookupRepositoryIdentity = Effect.fn("resolvePrLookupRepositoryIdentity")( |
| | 2411 | function* ( |
| | 2412 | cwd: string, |
| | 2413 | branch: string, |
| | 2414 | remoteNameOverride: string | undefined, |
| | 2415 | fresh: boolean, |
| | 2416 | ) { |
| | 2417 | const readConfig = (key: string) => |
| | 2418 | rememberPrLookupGitState( |
| | 2419 | cwd, |
| | 2420 | `config\u0000${key}`, |
| | 2421 | fresh, |
| | 2422 | gitCore.readConfigValue(cwd, key), |
| | 2423 | ).pipe(Effect.orElseSucceed(() => null)); |
| | 2424 | const readRemoteUrlKey = (remoteName: string | null) => |
| | 2425 | remoteName === null |
| | 2426 | ? Effect.succeed(null) |
| | 2427 | : readConfig(`remote.${remoteName}.url`).pipe( |
| | 2428 | Effect.map((remoteUrl) => (remoteUrl ? normalizeGitRemoteUrl(remoteUrl) : null)), |
| | 2429 | ); |
| | 2430 | const remoteName = remoteNameOverride ?? (yield* readConfig(`branch.${branch}.remote`)); |
| | 2431 | const [headRemoteUrlKey, targetRemoteUrlKey] = |
| | 2432 | remoteName === "origin" |
| | 2433 | ? yield* readRemoteUrlKey("origin").pipe(Effect.map((key) => [key, key] as const)) |
| | 2434 | : yield* Effect.all([readRemoteUrlKey(remoteName), readRemoteUrlKey("origin")], { |
| | 2435 | concurrency: "unbounded", |
| | 2436 | }); |
| | 2437 | return { |
| | 2438 | remoteName, |
| | 2439 | headRemoteUrlKey: headRemoteUrlKey ?? (remoteName === null ? targetRemoteUrlKey : null), |
| | 2440 | targetRemoteUrlKey, |
| | 2441 | }; |
| | 2442 | }, |
| | 2443 | ); |
| | 2444 | const lookupBranchPullRequest = Effect.fn("lookupBranchPullRequest")(function* ( |
| | 2445 | cacheCwd: string, |
| | 2446 | branch: string, |
| | 2447 | options: { readonly refresh: boolean; readonly freshGitState: boolean }, |
| | 2448 | ) { |
| | 2449 | const remember = <E>(key: string, read: Effect.Effect<string | null, E>) => |
| | 2450 | rememberPrLookupGitState(cacheCwd, key, options.freshGitState, read); |
| | 2451 | const remotes = yield* remember( |
| | 2452 | "remotes", |
| | 2453 | gitCore |
| | 2454 | .execute({ |
| | 2455 | operation: "GitManager.branchPullRequest.remotes", |
| | 2456 | cwd: cacheCwd, |
| | 2457 | args: ["remote"], |
| | 2458 | }) |
| | 2459 | .pipe(Effect.map((result) => result.stdout)), |
| | 2460 | ); |
| | 2461 | const remoteNames = (remotes ?? "") |
| 2370 | .split("\n") | 2462 | .split("\n") |
| 2371 | .map((remoteName) => remoteName.trim()) | 2463 | .map((remoteName) => remoteName.trim()) |
| 2372 | .filter((remoteName) => remoteName.length > 0); | 2464 | .filter((remoteName) => remoteName.length > 0); |
| 2373 | const [firstRemoteName] = remoteNames; | 2465 | const [firstRemoteName] = remoteNames; |
| 2374 | if (firstRemoteName === undefined) return null; | 2466 | if (firstRemoteName === undefined) return null; |
| 2375 | const branchRef = yield* gitCore.execute({ | 2467 | const branchRef = yield* remember( |
| 2376 | operation: "GitManager.branchPullRequest.branchRef", | 2468 | `branchRef\u0000${branch}`, |
| 2377 | cwd: cacheCwd, | 2469 | gitCore |
| 2378 | args: [ | 2470 | .execute({ |
| 2379 | "for-each-ref", | 2471 | operation: "GitManager.branchPullRequest.branchRef", |
| 2380 | "--format=%(refname)%00%(upstream:short)%00%(upstream:remotename)%00%(upstream:remoteref)", | 2472 | cwd: cacheCwd, |
| 2381 | `refs/heads/${branch}`, | 2473 | args: [ |
| 2382 | ], | 2474 | "for-each-ref", |
| 2383 | }); | 2475 | "--format=%(refname)%00%(upstream:short)%00%(upstream:remotename)%00%(upstream:remoteref)", |
| | 2476 | `refs/heads/${branch}`, |
| | 2477 | ], |
| | 2478 | }) |
| | 2479 | .pipe(Effect.map((result) => result.stdout)), |
| | 2480 | ); |
| 2384 | const expectedRefName = `refs/heads/${branch}`; | 2481 | const expectedRefName = `refs/heads/${branch}`; |
| 2385 | const exactBranch = branchRef.stdout | 2482 | const exactBranch = (branchRef ?? "") |
| 2386 | .split("\n") | 2483 | .split("\n") |
| 2387 | .find((line) => line.split("\u0000", 1)[0] === expectedRefName); | 2484 | .find((line) => line.split("\u0000", 1)[0] === expectedRefName); |
| 2388 | const [refName = "", savedUpstream = "", savedRemoteName = "", savedRemoteRef = ""] = | 2485 | const [refName = "", savedUpstream = "", savedRemoteName = "", savedRemoteRef = ""] = |
| ... | @@ -2402,20 +2499,31 @@ export const make = Effect.gen(function* () { | ... | @@ -2402,20 +2499,31 @@ export const make = Effect.gen(function* () { |
| 2402 | const upstreamBranch = savedRemoteRef.replace(/^refs\/heads\//, ""); | 2499 | const upstreamBranch = savedRemoteRef.replace(/^refs\/heads\//, ""); |
| 2403 | upstreamRef = `${remoteName}/${upstreamBranch}`; | 2500 | upstreamRef = `${remoteName}/${upstreamBranch}`; |
| 2404 | } else if (!localBranchExists) { | 2501 | } else if (!localBranchExists) { |
| 2405 | const trackingRefs = yield* gitCore.execute({ | 2502 | const trackingRemotes = yield* remember( |
| 2406 | operation: "GitManager.branchPullRequest.remoteTrackingRefs", | 2503 | `trackingRemotes\u0000${branch}`, |
| 2407 | cwd: cacheCwd, | 2504 | gitCore |
| 2408 | args: ["for-each-ref", "--format=%(refname)", "refs/remotes"], | 2505 | .execute({ |
| 2409 | }); | 2506 | operation: "GitManager.branchPullRequest.remoteTrackingRefs", |
| 2410 | const refNames = new Set( | 2507 | cwd: cacheCwd, |
| 2411 | trackingRefs.stdout | 2508 | args: ["for-each-ref", "--format=%(refname)", "refs/remotes"], |
| 2412 | .split("\n") | 2509 | }) |
| 2413 | .map((remoteRef) => remoteRef.trim()) | 2510 | .pipe( |
| 2414 | .filter((remoteRef) => remoteRef.length > 0), | 2511 | Effect.map((trackingRefs) => { |
| 2415 | ); | 2512 | const refNames = new Set( |
| 2416 | const matchingRemoteNames = remoteNames.filter((candidate) => | 2513 | trackingRefs.stdout |
| 2417 | refNames.has(`refs/remotes/${candidate}/${branch}`), | 2514 | .split("\n") |
| | 2515 | .map((remoteRef) => remoteRef.trim()) |
| | 2516 | .filter((remoteRef) => remoteRef.length > 0), |
| | 2517 | ); |
| | 2518 | return remoteNames |
| | 2519 | .filter((candidate) => refNames.has(`refs/remotes/${candidate}/${branch}`)) |
| | 2520 | .join("\n"); |
| | 2521 | }), |
| | 2522 | ), |
| 2418 | ); | 2523 | ); |
| | 2524 | const matchingRemoteNames = (trackingRemotes ?? "") |
| | 2525 | .split("\n") |
| | 2526 | .filter((candidate) => candidate.length > 0); |
| 2419 | if (matchingRemoteNames.length > 1) { | 2527 | if (matchingRemoteNames.length > 1) { |
| 2420 | return yield* new GitManagerError({ | 2528 | return yield* new GitManagerError({ |
| 2421 | operation: "branchPullRequest", | 2529 | operation: "branchPullRequest", |
| ... | @@ -2429,9 +2537,10 @@ export const make = Effect.gen(function* () { | ... | @@ -2429,9 +2537,10 @@ export const make = Effect.gen(function* () { |
| 2429 | } | 2537 | } |
| 2430 | } | 2538 | } |
| 2431 | const defaultRemoteName = remoteNames.includes("origin") ? "origin" : firstRemoteName; | 2539 | const defaultRemoteName = remoteNames.includes("origin") ? "origin" : firstRemoteName; |
| 2432 | const defaultBranch = yield* gitCore | 2540 | const defaultBranch = yield* remember( |
| 2433 | .resolveDefaultBranchName(cacheCwd, defaultRemoteName) | 2541 | `defaultBranch\u0000${defaultRemoteName}`, |
| 2434 | .pipe(Effect.orElseSucceed(() => null)); | 2542 | gitCore.resolveDefaultBranchName(cacheCwd, defaultRemoteName), |
| | 2543 | ).pipe(Effect.orElseSucceed(() => null)); |
| 2435 | const cacheKey = prLookupCacheKey(cacheCwd, { | 2544 | const cacheKey = prLookupCacheKey(cacheCwd, { |
| 2436 | branch, | 2545 | branch, |
| 2437 | upstreamRef, | 2546 | upstreamRef, |
| ... | @@ -2439,7 +2548,7 @@ export const make = Effect.gen(function* () { | ... | @@ -2439,7 +2548,7 @@ export const make = Effect.gen(function* () { |
| 2439 | localBranchExists, | 2548 | localBranchExists, |
| 2440 | ...(localBranchExists ? {} : { remoteName }), | 2549 | ...(localBranchExists ? {} : { remoteName }), |
| 2441 | }); | 2550 | }); |
| 2442 | if (options?.refresh) { | 2551 | if (options.refresh) { |
| 2443 | // A completed turn can create a PR or reuse a merged PR's branch. | 2552 | // A completed turn can create a PR or reuse a merged PR's branch. |
| 2444 | // Refresh successful answers, but keep failed lookups' retry backoff. | 2553 | // Refresh successful answers, but keep failed lookups' retry backoff. |
| 2445 | const cached = yield* Cache.getOption(prLookupCache, cacheKey).pipe( | 2554 | const cached = yield* Cache.getOption(prLookupCache, cacheKey).pipe( |
| ... | @@ -2457,6 +2566,7 @@ export const make = Effect.gen(function* () { | ... | @@ -2457,6 +2566,7 @@ export const make = Effect.gen(function* () { |
| 2457 | cacheCwd, | 2566 | cacheCwd, |
| 2458 | branch, | 2567 | branch, |
| 2459 | identityRemoteName(cached.headContext), | 2568 | identityRemoteName(cached.headContext), |
| | 2569 | options.freshGitState, |
| 2460 | ); | 2570 | ); |
| 2461 | const canVerifyIdentity = (headContext: BranchHeadContext, identity: typeof currentIdentity) => | 2571 | const canVerifyIdentity = (headContext: BranchHeadContext, identity: typeof currentIdentity) => |
| 2462 | !( | 2572 | !( |
| ... | @@ -2480,6 +2590,7 @@ export const make = Effect.gen(function* () { | ... | @@ -2480,6 +2590,7 @@ export const make = Effect.gen(function* () { |
| 2480 | cacheCwd, | 2590 | cacheCwd, |
| 2481 | branch, | 2591 | branch, |
| 2482 | identityRemoteName(cached.headContext), | 2592 | identityRemoteName(cached.headContext), |
| | 2593 | true, |
| 2483 | ); | 2594 | ); |
| 2484 | if ( | 2595 | if ( |
| 2485 | !canVerifyIdentity(cached.headContext, refreshedIdentity) || | 2596 | !canVerifyIdentity(cached.headContext, refreshedIdentity) || |
| ... | @@ -2511,6 +2622,22 @@ export const make = Effect.gen(function* () { | ... | @@ -2511,6 +2622,22 @@ export const make = Effect.gen(function* () { |
| 2511 | repositoryKey: pullRequestRepositoryKey(latest.url), | 2622 | repositoryKey: pullRequestRepositoryKey(latest.url), |
| 2512 | }; | 2623 | }; |
| 2513 | }); | 2624 | }); |
| | 2625 | const branchPullRequest: GitManager["Service"]["branchPullRequest"] = Effect.fn( |
| | 2626 | "branchPullRequest", |
| | 2627 | )(function* ({ cwd, branch }, options) { |
| | 2628 | const cacheCwd = yield* normalizeStatusCacheKey(cwd); |
| | 2629 | const refresh = options?.refresh === true; |
| | 2630 | const freshGitState = refresh || options?.freshGitState === true; |
| | 2631 | const pullRequest = yield* lookupBranchPullRequest(cacheCwd, branch, { |
| | 2632 | refresh, |
| | 2633 | freshGitState, |
| | 2634 | }); |
| | 2635 | if (pullRequest === null || pullRequest.state === "open" || freshGitState) return pullRequest; |
| | 2636 | return yield* lookupBranchPullRequest(cacheCwd, branch, { |
| | 2637 | refresh: false, |
| | 2638 | freshGitState: true, |
| | 2639 | }); |
| | 2640 | }); |
| 2514 | const invalidateLocalStatus: GitManager["Service"]["invalidateLocalStatus"] = Effect.fn( | 2641 | const invalidateLocalStatus: GitManager["Service"]["invalidateLocalStatus"] = Effect.fn( |
| 2515 | "invalidateLocalStatus", | 2642 | "invalidateLocalStatus", |
| 2516 | )(function* (cwd) { | 2643 | )(function* (cwd) { |
| ... | @@ -2529,6 +2656,7 @@ export const make = Effect.gen(function* () { | ... | @@ -2529,6 +2656,7 @@ export const make = Effect.gen(function* () { |
| 2529 | // refresh); it also bypasses the slow PR-lookup cache. The periodic | 2656 | // refresh); it also bypasses the slow PR-lookup cache. The periodic |
| 2530 | // status poll only invalidates local/remote and keeps the PR cache warm. | 2657 | // status poll only invalidates local/remote and keeps the PR cache warm. |
| 2531 | yield* bumpPrLookupEpoch(cwd); | 2658 | yield* bumpPrLookupEpoch(cwd); |
| | 2659 | yield* forgetPrLookupGitState(cwd); |
| 2532 | }, | 2660 | }, |
| 2533 | ); | 2661 | ); |
| 2534 | | 2662 | |