| ... | @@ -65,6 +65,8 @@ interface ExecuteGitOptions { | ... | @@ -65,6 +65,8 @@ interface ExecuteGitOptions { |
| 65 | } | 65 | } |
| 66 | | 66 | |
| 67 | const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000; | 67 | const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000; |
| | 68 | const STATUS_UPSTREAM_REFRESH_INTERVAL_MS = 15_000; |
| | 69 | const STATUS_UPSTREAM_REFRESH_TIMEOUT_MS = 5_000; |
| 68 | | 70 | |
| 69 | /** Spawn git directly with an argv array — no shell, no quoting needed. */ | 71 | /** Spawn git directly with an argv array — no shell, no quoting needed. */ |
| 70 | function runGit( | 72 | function runGit( |
| ... | @@ -216,6 +218,8 @@ async function executeGit( | ... | @@ -216,6 +218,8 @@ async function executeGit( |
| 216 | } | 218 | } |
| 217 | | 219 | |
| 218 | export class GitCoreService { | 220 | export class GitCoreService { |
| | 221 | private readonly lastUpstreamRefreshAt = new Map<string, number>(); |
| | 222 | |
| 219 | async status(raw: GitStatusInput): Promise<GitStatusResult> { | 223 | async status(raw: GitStatusInput): Promise<GitStatusResult> { |
| 220 | const input = gitStatusInputSchema.parse(raw); | 224 | const input = gitStatusInputSchema.parse(raw); |
| 221 | const details = await this.statusDetails(input.cwd); | 225 | const details = await this.statusDetails(input.cwd); |
| ... | @@ -231,6 +235,8 @@ export class GitCoreService { | ... | @@ -231,6 +235,8 @@ export class GitCoreService { |
| 231 | } | 235 | } |
| 232 | | 236 | |
| 233 | async statusDetails(cwd: string): Promise<GitStatusDetails> { | 237 | async statusDetails(cwd: string): Promise<GitStatusDetails> { |
| | 238 | await this.refreshStatusUpstreamIfStale(cwd).catch(() => undefined); |
| | 239 | |
| 234 | const [statusStdout, unstagedNumstatStdout, stagedNumstatStdout] = await Promise.all([ | 240 | const [statusStdout, unstagedNumstatStdout, stagedNumstatStdout] = await Promise.all([ |
| 235 | this.gitStdout(cwd, ["status", "--porcelain=2", "--branch"]), | 241 | this.gitStdout(cwd, ["status", "--porcelain=2", "--branch"]), |
| 236 | this.gitStdout(cwd, ["diff", "--numstat"]), | 242 | this.gitStdout(cwd, ["diff", "--numstat"]), |
| ... | @@ -553,7 +559,9 @@ export class GitCoreService { | ... | @@ -553,7 +559,9 @@ export class GitCoreService { |
| 553 | }); | 559 | }); |
| 554 | } | 560 | } |
| 555 | | 561 | |
| 556 | private async refreshCheckedOutBranchUpstream(cwd: string): Promise<void> { | 562 | private async resolveCurrentUpstream( |
| | 563 | cwd: string, |
| | 564 | ): Promise<{ upstreamRef: string; remoteName: string; upstreamBranch: string } | null> { |
| 557 | const upstreamRef = trimStdout( | 565 | const upstreamRef = trimStdout( |
| 558 | await this.gitStdout( | 566 | await this.gitStdout( |
| 559 | cwd, | 567 | cwd, |
| ... | @@ -562,24 +570,72 @@ export class GitCoreService { | ... | @@ -562,24 +570,72 @@ export class GitCoreService { |
| 562 | ), | 570 | ), |
| 563 | ); | 571 | ); |
| 564 | if (upstreamRef.length === 0 || upstreamRef === "@{upstream}") { | 572 | if (upstreamRef.length === 0 || upstreamRef === "@{upstream}") { |
| 565 | return; | 573 | return null; |
| 566 | } | 574 | } |
| 567 | | 575 | |
| 568 | const separatorIndex = upstreamRef.indexOf("/"); | 576 | const separatorIndex = upstreamRef.indexOf("/"); |
| 569 | if (separatorIndex <= 0) { | 577 | if (separatorIndex <= 0) { |
| 570 | return; | 578 | return null; |
| 571 | } | 579 | } |
| 572 | const remoteName = upstreamRef.slice(0, separatorIndex); | 580 | const remoteName = upstreamRef.slice(0, separatorIndex); |
| 573 | const upstreamBranch = upstreamRef.slice(separatorIndex + 1); | 581 | const upstreamBranch = upstreamRef.slice(separatorIndex + 1); |
| 574 | if (remoteName.length === 0) { | 582 | if (remoteName.length === 0) { |
| 575 | return; | 583 | return null; |
| 576 | } | 584 | } |
| 577 | if (upstreamBranch.length === 0) { | 585 | if (upstreamBranch.length === 0) { |
| | 586 | return null; |
| | 587 | } |
| | 588 | |
| | 589 | return { |
| | 590 | upstreamRef, |
| | 591 | remoteName, |
| | 592 | upstreamBranch, |
| | 593 | }; |
| | 594 | } |
| | 595 | |
| | 596 | private async fetchUpstreamRef( |
| | 597 | cwd: string, |
| | 598 | upstream: { upstreamRef: string; remoteName: string; upstreamBranch: string }, |
| | 599 | ): Promise<void> { |
| | 600 | const refspec = `+refs/heads/${upstream.upstreamBranch}:refs/remotes/${upstream.upstreamRef}`; |
| | 601 | await this.git(cwd, ["fetch", "--quiet", "--no-tags", upstream.remoteName, refspec], true); |
| | 602 | } |
| | 603 | |
| | 604 | private async fetchUpstreamRefForStatus( |
| | 605 | cwd: string, |
| | 606 | upstream: { upstreamRef: string; remoteName: string; upstreamBranch: string }, |
| | 607 | ): Promise<void> { |
| | 608 | const refspec = `+refs/heads/${upstream.upstreamBranch}:refs/remotes/${upstream.upstreamRef}`; |
| | 609 | await executeGit(cwd, ["fetch", "--quiet", "--no-tags", upstream.remoteName, refspec], { |
| | 610 | allowNonZeroExit: true, |
| | 611 | timeoutMs: STATUS_UPSTREAM_REFRESH_TIMEOUT_MS, |
| | 612 | }); |
| | 613 | } |
| | 614 | |
| | 615 | private async refreshStatusUpstreamIfStale(cwd: string): Promise<void> { |
| | 616 | const upstream = await this.resolveCurrentUpstream(cwd); |
| | 617 | if (!upstream) { |
| 578 | return; | 618 | return; |
| 579 | } | 619 | } |
| 580 | | 620 | |
| 581 | const refspec = `+refs/heads/${upstreamBranch}:refs/remotes/${upstreamRef}`; | 621 | const cacheKey = `${cwd}\u0000${upstream.upstreamRef}`; |
| 582 | await this.git(cwd, ["fetch", "--quiet", "--no-tags", remoteName, refspec], true); | 622 | const now = Date.now(); |
| | 623 | const lastRefreshAt = this.lastUpstreamRefreshAt.get(cacheKey) ?? 0; |
| | 624 | if (now - lastRefreshAt < STATUS_UPSTREAM_REFRESH_INTERVAL_MS) { |
| | 625 | return; |
| | 626 | } |
| | 627 | |
| | 628 | // Record attempt time first to avoid repeated fetch spikes when status is polled often. |
| | 629 | this.lastUpstreamRefreshAt.set(cacheKey, now); |
| | 630 | await this.fetchUpstreamRefForStatus(cwd, upstream); |
| | 631 | } |
| | 632 | |
| | 633 | private async refreshCheckedOutBranchUpstream(cwd: string): Promise<void> { |
| | 634 | const upstream = await this.resolveCurrentUpstream(cwd); |
| | 635 | if (!upstream) { |
| | 636 | return; |
| | 637 | } |
| | 638 | await this.fetchUpstreamRef(cwd, upstream); |
| 583 | } | 639 | } |
| 584 | | 640 | |
| 585 | async checkoutBranch(input: GitCheckoutInput): Promise<void> { | 641 | async checkoutBranch(input: GitCheckoutInput): Promise<void> { |