-
Notifications
You must be signed in to change notification settings - Fork 13
Expand file tree
/
Copy pathworktree.ts
More file actions
1993 lines (1834 loc) · 66.3 KB
/
Copy pathworktree.ts
File metadata and controls
1993 lines (1834 loc) · 66.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import { execFile, spawn } from 'child_process'
import { promisify } from 'util'
import { basename, join, resolve, relative, isAbsolute } from 'path'
import {
existsSync,
mkdirSync,
statSync,
lstatSync,
symlinkSync,
unlinkSync,
readFileSync,
writeFileSync
} from 'fs'
import { readFile, writeFile } from 'fs/promises'
import { log } from './debug'
import { perfLog } from './perf-log'
import { resolveUserShell, loginShellCommandArgs } from './user-shell'
import { detectInProgressOp } from './git-ops-state'
import { cachedGitRead } from './git-poll-cache'
import { runGitRead, type GitPriority } from './git-limiter'
import type { Worktree } from '../shared/state/worktrees'
const execFileAsync = promisify(execFile)
type ExecOpts = NonNullable<Parameters<typeof execFileAsync>[2]>
/** `status` and `diff` opportunistically refresh *and write* the index, which
* takes .git/index.lock — so a background poll can make a rebase running in the
* same worktree's PTY fail with "Unable to create index.lock: File exists".
* GIT_OPTIONAL_LOCKS=0 skips the write-back; output is unchanged. Built per
* call rather than hoisted: path-fix.ts merges into process.env.PATH at boot,
* and a module-level snapshot could capture the pre-merge value. */
function readOnlyGitEnv(): NodeJS.ProcessEnv {
return { ...process.env, GIT_OPTIONAL_LOCKS: '0' }
}
/** Every read-only git spawn goes through here so the concurrency gate in
* git-limiter.ts sees all of them. Gating at the leaf exec — rather than around
* a whole helper — is what keeps callers that issue several reads in sequence
* (getMainWorktreeStatus, resolveDefaultBaseRef) from holding a permit while
* waiting for one, which would deadlock at the cap. Writes bypass this on
* purpose; see the module comment in git-limiter.ts. */
function execGitRead(
args: string[],
opts: ExecOpts,
priority: GitPriority = 'interactive'
): Promise<{ stdout: string }> {
return gatedExec(args, opts, priority)
}
/** Shared body of execGitRead/tracedExec. `execMs` is measured *inside* the
* gate so it stays comparable with pre-limiter `[git-op]` lines — time spent
* queueing is reported separately as `waitMs` rather than folded into exec. */
async function gatedExec(
args: string[],
opts: ExecOpts,
priority: GitPriority
): Promise<{ stdout: string; execMs: number; waitMs: number }> {
const queued = performance.now()
return runGitRead(priority, async () => {
const started = performance.now()
const { stdout } = await execFileAsync('git', args, { env: readOnlyGitEnv(), ...opts })
return {
stdout: typeof stdout === 'string' ? stdout : stdout.toString(),
execMs: performance.now() - started,
waitMs: started - queued
}
})
}
async function tracedExec(
args: string[],
opts: ExecOpts
): Promise<{ stdout: string; execMs: number; outputBytes: number }> {
const { stdout, execMs } = await gatedExec(args, opts, 'interactive')
return { stdout, execMs, outputBytes: stdout.length }
}
// Alias so existing imports of WorktreeInfo keep working; the canonical
// shape now lives in src/shared/state/worktrees.ts.
export type WorktreeInfo = Worktree
function getCreatedAt(path: string): number {
try {
const s = statSync(path)
return s.birthtimeMs || s.ctimeMs || 0
} catch {
return 0
}
}
/** Get a sensible default directory for worktrees: <repo>-worktrees/ alongside the repo */
export function defaultWorktreeDir(repoRoot: string): string {
const repoName = basename(repoRoot)
return join(repoRoot, '..', `${repoName}-worktrees`)
}
/** Pure `git worktree list --porcelain` parser. Extracted from
* listWorktrees so the prunable-handling logic can be unit-tested
* without shelling out. See the porcelain format docs:
* https://git-scm.com/docs/git-worktree#_porcelain_format. */
export function parseWorktreeListPorcelain(
stdout: string,
repoRoot: string
): WorktreeInfo[] {
const worktrees: WorktreeInfo[] = []
let current: Partial<WorktreeInfo> = {}
const flush = (): void => {
if (!current.path) return
worktrees.push({
path: current.path,
branch: current.branch || '(detached)',
head: current.head || '',
isBare: current.isBare || false,
isMain: current.path === repoRoot,
createdAt: getCreatedAt(current.path),
repoRoot,
...(current.prunable ? { prunable: true } : {}),
...(current.prunableReason ? { prunableReason: current.prunableReason } : {})
})
current = {}
}
for (const line of stdout.split('\n')) {
if (line.startsWith('worktree ')) {
current.path = line.slice('worktree '.length)
} else if (line.startsWith('HEAD ')) {
current.head = line.slice('HEAD '.length)
} else if (line.startsWith('branch ')) {
current.branch = line.slice('branch '.length).replace('refs/heads/', '')
} else if (line === 'bare') {
current.isBare = true
} else if (line === 'prunable' || line.startsWith('prunable ')) {
// `prunable` marks entries whose on-disk directory was deleted
// without a subsequent `git worktree prune`. Value form is
// `prunable <reason>` (e.g. "gitdir file points to non-existent
// location"); bare `prunable` also occurs.
current.prunable = true
const rest = line.slice('prunable'.length).trim()
if (rest) current.prunableReason = rest
} else if (line === '') {
flush()
}
}
// Trailing entry without a blank line — git doesn't always emit a
// terminal blank on the last record.
flush()
return worktrees
}
export async function listWorktrees(repoRoot: string): Promise<WorktreeInfo[]> {
const { stdout } = await execGitRead(['worktree', 'list', '--porcelain'], {
cwd: repoRoot
})
const worktrees = parseWorktreeListPorcelain(stdout, repoRoot)
const detached = worktrees.filter((w) => w.branch === '(detached)' && !w.isBare)
if (detached.length > 0) {
const ops = await Promise.all(detached.map((w) => detectInProgressOp(w.path).catch(() => null)))
detached.forEach((w, i) => {
const op = ops[i]
if (op) w.branch = op.label
})
}
return worktrees
}
/** Fetch a PR's head ref into a named local branch. Force so a
* re-opened review picks up new commits without complaining about
* non-fast-forward.
*
* Also points `refs/remotes/origin/<localBranch>` at the fetched SHA
* so the unpushed-commit detector (which reads `origin/<branch>` to
* figure out what's been published) treats the PR head as the
* upstream of record. Without this, every commit in a PR-review
* worktree shows up as "unpushed" in the Commits sidebar. */
export async function fetchPullRequestRef(
repoRoot: string,
prNumber: number,
localBranch: string
): Promise<void> {
log('worktree', `fetching pull/${prNumber}/head into ${localBranch}`)
await execFileAsync(
'git',
['fetch', 'origin', `+refs/pull/${prNumber}/head:refs/heads/${localBranch}`],
{ cwd: repoRoot }
)
try {
const { stdout } = await execFileAsync(
'git',
['rev-parse', '--verify', `refs/heads/${localBranch}`],
{ cwd: repoRoot }
)
const sha = stdout.trim()
if (sha) {
await execFileAsync(
'git',
['update-ref', `refs/remotes/origin/${localBranch}`, sha],
{ cwd: repoRoot }
)
}
} catch (err) {
// Best-effort. The worst case is the Commits sidebar mislabels
// existing PR commits as unpushed — annoying but not blocking.
log(
'worktree',
`failed to update remote-tracking ref for ${localBranch}`,
err instanceof Error ? err.message : err
)
}
}
/** True if a local branch with this name already exists in the repo. */
export async function localBranchExists(repoRoot: string, branchName: string): Promise<boolean> {
try {
await execGitRead(['rev-parse', '--verify', '--quiet', `refs/heads/${branchName}`], {
cwd: repoRoot
})
return true
} catch {
return false
}
}
export async function listBranches(repoRoot: string): Promise<string[]> {
// Local branches only. Remote-tracking refs (`origin/*`) are intentionally
// excluded — hundreds of remote branches make the picker UI unusable. Users
// who need a remote ref can type it into the Ref tab on the New worktree
// screen.
const { stdout } = await execGitRead(
['branch', '--format=%(refname:short)'],
{ cwd: repoRoot }
)
return stdout.trim().split('\n').filter(Boolean)
}
export interface AddWorktreeOptions {
/** Explicit base branch to fork from. Overrides fetchRemote detection. */
baseBranch?: string
/** If true, fetch the default branch from origin before creating so the
* new worktree starts at the tip of the latest remote main. Falls back
* to local HEAD if the fetch fails (e.g. offline). */
fetchRemote?: boolean
/** When set, skip `-b` and check out the named branch as-is. Used by
* the open-PR flow, where the local branch was already created by a
* `git fetch origin pull/<N>/head:pr-<N>` ahead of this call. */
checkoutExisting?: boolean
}
/**
* Resolve a base ref to fork/branch from, optionally fetching origin first.
* Matches the same logic addWorktree and continueWorktree share:
* explicit baseBranch wins; else if fetchRemote, fetch origin's default
* branch and use origin/<default>; else return undefined (caller uses HEAD).
*/
async function resolveBaseRef(
repoRoot: string,
options: { baseBranch?: string; fetchRemote?: boolean }
): Promise<string | undefined> {
if (options.baseBranch) return options.baseBranch
if (!options.fetchRemote) return undefined
try {
const defaultRef = await getDefaultBaseRef(repoRoot)
const remoteBranch = defaultRef.startsWith('origin/')
? defaultRef.slice('origin/'.length)
: defaultRef
if (remoteBranch && remoteBranch !== 'HEAD') {
log('worktree', `fetching origin ${remoteBranch}`)
await execFileAsync('git', ['fetch', '--quiet', 'origin', remoteBranch], { cwd: repoRoot })
}
const resolvedRef = await getDefaultBaseRef(repoRoot)
if (resolvedRef && resolvedRef !== 'HEAD') return resolvedRef
} catch (err) {
log('worktree', `remote fetch failed, falling back to local HEAD`, err instanceof Error ? err.message : err)
}
return undefined
}
export async function addWorktree(
repoRoot: string,
worktreeDir: string,
branchName: string,
options: AddWorktreeOptions = {}
): Promise<WorktreeInfo> {
// Ensure worktree directory exists
if (!existsSync(worktreeDir)) {
mkdirSync(worktreeDir, { recursive: true })
}
const worktreePath = join(worktreeDir, branchName)
if (options.checkoutExisting) {
log('worktree', `creating worktree from existing branch: branch=${branchName} path=${worktreePath}`)
await execFileAsync('git', ['worktree', 'add', worktreePath, branchName], {
cwd: repoRoot
})
} else {
const baseRef = await resolveBaseRef(repoRoot, options)
log('worktree', `creating worktree: branch=${branchName} path=${worktreePath} base=${baseRef || 'HEAD'}`)
const args = ['worktree', 'add', worktreePath, '-b', branchName]
if (baseRef) {
args.push(baseRef)
}
try {
await execFileAsync('git', args, { cwd: repoRoot })
} catch (err) {
// If branch already exists, try checking it out instead of creating
if (err instanceof Error && err.message.includes('already exists')) {
await execFileAsync('git', ['worktree', 'add', worktreePath, branchName], {
cwd: repoRoot
})
} else {
throw err
}
}
}
const trees = await listWorktrees(repoRoot)
const created = trees.find((t) => t.path === worktreePath)
if (!created) throw new Error(`Failed to create worktree ${branchName}`)
return created
}
export interface ContinueWorktreeResult {
worktree: WorktreeInfo
/** Dirty files were stashed and successfully re-applied. */
stashReapplied: boolean
/** Dirty files are still in the stash because pop conflicted. */
stashConflict: boolean
}
/**
* Reuse an existing worktree path and re-point it at a brand new branch
* forked from the repo's default base (optionally fetching origin first).
* If the worktree has uncommitted changes, they are stashed before the
* checkout and popped afterward so the user's in-progress work carries
* over to the fresh branch.
*/
export async function continueWorktree(
repoRoot: string,
worktreePath: string,
newBranchName: string,
options: AddWorktreeOptions = {}
): Promise<ContinueWorktreeResult> {
const baseRef = await resolveBaseRef(repoRoot, options)
log(
'worktree',
`continuing worktree: path=${worktreePath} newBranch=${newBranchName} base=${baseRef || 'HEAD'}`
)
const dirty = await isWorktreeDirty(worktreePath)
let stashed = false
if (dirty) {
const stashMsg = `harness-continue ${newBranchName} ${Date.now()}`
await execFileAsync('git', ['stash', 'push', '--include-untracked', '-m', stashMsg], {
cwd: worktreePath
})
stashed = true
}
const checkoutArgs = ['checkout', '-b', newBranchName]
if (baseRef) checkoutArgs.push(baseRef)
try {
await execFileAsync('git', checkoutArgs, { cwd: worktreePath })
} catch (err) {
if (stashed) {
// Best-effort: try to restore dirty state so user isn't stranded
try {
await execFileAsync('git', ['stash', 'pop'], { cwd: worktreePath })
} catch {}
}
throw err
}
let stashReapplied = false
let stashConflict = false
if (stashed) {
try {
await execFileAsync('git', ['stash', 'pop'], { cwd: worktreePath })
stashReapplied = true
} catch {
// Pop left changes in a conflict state; stash entry is preserved.
stashConflict = true
}
}
const trees = await listWorktrees(repoRoot)
const updated = trees.find((t) => t.path === worktreePath)
if (!updated) throw new Error(`Failed to locate worktree ${worktreePath} after continue`)
return { worktree: updated, stashReapplied, stashConflict }
}
/** Check if a worktree has uncommitted changes.
*
* `priority` exists for the Cleanup modal, which asks this of every worktree at
* once. At 'bulk' that sweep queues behind anything the user is actually
* looking at instead of burying it. */
export async function isWorktreeDirty(
path: string,
priority: GitPriority = 'interactive'
): Promise<boolean> {
try {
const { stdout } = await execGitRead(['status', '--porcelain'], { cwd: path }, priority)
return stdout.trim().length > 0
} catch {
return false
}
}
export interface ChangedFile {
path: string
status: 'added' | 'modified' | 'deleted' | 'renamed' | 'untracked'
staged: boolean
/** Lines added. Undefined for binary files (numstat reports `-`) and
* untracked files (no diff baseline yet). */
additions?: number
/** Lines deleted. Undefined for binary files and untracked files. */
deletions?: number
}
export type ChangedFilesMode = 'working' | 'branch'
const BASE_REF_TTL_MS = 5 * 60 * 1000
const baseRefCache = new Map<string, { ref: string; at: number }>()
/** Detect the repo's default base branch (e.g. "main" or "master").
*
* Memoized because the polled panels call this twice per tick and it costs up
* to five git spawns (symbolic-ref, then a rev-parse per candidate) to answer
* a question whose answer effectively never changes. The 'HEAD' fallback is
* deliberately not cached — it means nothing resolved yet, which is the state
* a fresh worktree is in mid-setup, and it flips as soon as the remote lands. */
export async function getDefaultBaseRef(worktreePath: string): Promise<string> {
const hit = baseRefCache.get(worktreePath)
if (hit && Date.now() - hit.at < BASE_REF_TTL_MS) return hit.ref
const ref = await resolveDefaultBaseRef(worktreePath)
if (ref !== 'HEAD') baseRefCache.set(worktreePath, { ref, at: Date.now() })
return ref
}
async function resolveDefaultBaseRef(worktreePath: string): Promise<string> {
try {
const { stdout } = await execGitRead(
['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'],
{ cwd: worktreePath }
)
const ref = stdout.trim()
if (ref) return ref
} catch {}
for (const candidate of ['origin/main', 'origin/master', 'main', 'master']) {
try {
await execGitRead(['rev-parse', '--verify', candidate], { cwd: worktreePath })
return candidate
} catch {}
}
return 'HEAD'
}
export interface BranchCommit {
hash: string
shortHash: string
subject: string
author: string
relativeDate: string
timestamp: number
pushed: boolean
}
const COMMIT_FIELD_SEP = '\x1f'
const COMMIT_RECORD_SEP = '\x1e'
export const BRANCH_COMMIT_PRETTY_FORMAT =
`format:%H${COMMIT_FIELD_SEP}%h${COMMIT_FIELD_SEP}%s${COMMIT_FIELD_SEP}%an${COMMIT_FIELD_SEP}%ar${COMMIT_FIELD_SEP}%at${COMMIT_RECORD_SEP}`
export function parseBranchCommitLog(
stdout: string,
unpushed: Set<string> | null
): BranchCommit[] {
const result: BranchCommit[] = []
for (const record of stdout.split(COMMIT_RECORD_SEP)) {
const line = record.trim()
if (!line) continue
const parts = line.split(COMMIT_FIELD_SEP)
if (parts.length < 6) continue
const [hash, shortHash, subject, author, relativeDate, ts] = parts
const pushed = unpushed === null ? false : !unpushed.has(hash)
result.push({
hash,
shortHash,
subject,
author,
relativeDate,
timestamp: Number(ts) || 0,
pushed
})
}
return result
}
/** Hashes of commits on this branch not yet reachable from origin/<branch>.
* Empty set if the branch has no remote tracking ref (all commits are local). */
async function getUnpushedHashes(worktreePath: string): Promise<Set<string> | null> {
const branch = await getCurrentBranch(worktreePath)
if (!branch) return null
const remoteRef = `refs/remotes/origin/${branch}`
try {
await execGitRead(['rev-parse', '--verify', '--quiet', remoteRef], {
cwd: worktreePath
})
} catch {
return null
}
try {
const { stdout } = await execGitRead(
['log', `origin/${branch}..HEAD`, '--pretty=format:%H', '--max-count=500'],
{ cwd: worktreePath }
)
return new Set(stdout.split('\n').filter(Boolean))
} catch {
return new Set()
}
}
/** Get commits unique to this branch (i.e. base..HEAD). */
export async function getBranchCommits(worktreePath: string): Promise<BranchCommit[]> {
const { value } = await cachedGitRead<BranchCommit[]>({
key: `${worktreePath}\x00commits`,
worktreePath,
fingerprintable: true,
baseRef: await getDefaultBaseRef(worktreePath),
read: () => getBranchCommitsImpl(worktreePath)
})
return value
}
async function getBranchCommitsImpl(worktreePath: string): Promise<BranchCommit[]> {
const t0 = performance.now()
let walledExec = 0
let cumExec = 0
let outputBytes = 0
const execParts: number[] = []
let result: BranchCommit[] = []
const tBase = performance.now()
const base = await getDefaultBaseRef(worktreePath)
const baseMs = performance.now() - tBase
walledExec += baseMs
cumExec += baseMs
execParts.push(baseMs)
if (base !== 'HEAD') {
try {
const tA = performance.now()
const [logRes, unpushed] = await Promise.all([
tracedExec(
[
'log',
`${base}..HEAD`,
`--pretty=${BRANCH_COMMIT_PRETTY_FORMAT}`,
'--max-count=200'
],
{ cwd: worktreePath }
),
getUnpushedHashes(worktreePath)
])
walledExec += performance.now() - tA
cumExec += logRes.execMs
execParts.push(logRes.execMs)
outputBytes += logRes.outputBytes
result = parseBranchCommitLog(logRes.stdout, unpushed)
} catch {
result = []
}
}
logGitOp('getBranchCommits', {}, t0, walledExec, cumExec, outputBytes, execParts)
return result
}
function mapNameStatus(code: string): ChangedFile['status'] {
const c = code[0]
if (c === 'A') return 'added'
if (c === 'D') return 'deleted'
if (c === 'R') return 'renamed'
if (c === 'C') return 'renamed'
return 'modified'
}
type NumstatCounts = { additions: number; deletions: number } | null
/** Parse `git diff --numstat -z` output. Rename rows look like
* `add\tdel\t\0oldpath\0newpath\0` and are keyed by the destination path.
* Binary files report `-` for both counts and map to null. */
function parseNumstatZ(stdout: string): Map<string, NumstatCounts> {
const map = new Map<string, NumstatCounts>()
const tokens = stdout.split('\0')
let i = 0
while (i < tokens.length) {
const tok = tokens[i]
if (!tok) { i++; continue }
const tabs = tok.split('\t')
if (tabs.length < 3) { i++; continue }
const [addStr, delStr, pathField] = tabs
const counts: NumstatCounts =
addStr === '-' || delStr === '-'
? null
: { additions: Number(addStr), deletions: Number(delStr) }
if (pathField === '') {
// Rename: next two NUL-separated tokens are old then new path.
const newPath = tokens[i + 2] ?? ''
if (newPath) map.set(newPath, counts)
i += 3
} else {
map.set(pathField, counts)
i++
}
}
return map
}
/** `command` exists because GIT_OPTIONAL_LOCKS=0 does not cover every form of
* `git diff`. It suppresses the index write-back for `status` and for
* `diff --cached`, but the unstaged worktree-vs-index `git diff` refreshes and
* writes the index anyway (git 2.50.1). That write lands in the gitdir
* WorktreeWatcher watches, and `index` is in CHANGED_FILES_RELEVANT — so the
* changed-files read retriggers its own invalidation, doubling the git work
* behind every edit. `diff-files` is the plumbing equivalent of that one form
* and does no opportunistic refresh, so the unstaged call uses it instead. */
async function numstatExec(
worktreePath: string,
args: string[],
command: 'diff' | 'diff-files' = 'diff'
): Promise<{ stdout: string; execMs: number; outputBytes: number }> {
try {
return await tracedExec([command, '--numstat', '-z', ...args], {
cwd: worktreePath,
maxBuffer: 16 * 1024 * 1024
})
} catch {
return { stdout: '', execMs: 0, outputBytes: 0 }
}
}
// These fire on every panel refresh for every worktree — thousands per minute
// in a busy session. Tracing all of them made the log (and its writes) a
// bigger cost than the thing being traced, so both are gated the same way
// every other perf category is: only the slow ones are worth a line.
const SLOW_GIT_OP_MS = 50
const SLOW_CHANGED_FILES_MS = 50
function logGitOp(
name: string,
ctx: Record<string, unknown>,
t0: number,
walledExec: number,
cumExec: number,
outputBytes: number,
execParts: number[]
): void {
const total = performance.now() - t0
const postMs = Math.max(0, total - walledExec)
if (cumExec < SLOW_GIT_OP_MS && total < SLOW_GIT_OP_MS) return
perfLog(
'git-op',
`${name} exec=${cumExec.toFixed(0)}ms post=${postMs.toFixed(0)}ms bytes=${outputBytes}`,
{
name,
...ctx,
execMs: +cumExec.toFixed(1),
postMs: +postMs.toFixed(1),
outputBytes,
execParts: execParts.map((n) => +n.toFixed(1))
}
)
}
/** Renamed entries from `git status --porcelain` are stored as
* "old -> new" — match numstat by the destination path. */
function destOf(p: string): string {
const idx = p.indexOf(' -> ')
return idx >= 0 ? p.slice(idx + 4) : p
}
function applyCounts(file: ChangedFile, counts: NumstatCounts | undefined): void {
if (!counts) return
file.additions = counts.additions
file.deletions = counts.deletions
}
/** Get changed files (staged, unstaged, and untracked) in a worktree */
export async function getChangedFiles(
worktreePath: string,
mode: ChangedFilesMode = 'working'
): Promise<ChangedFile[]> {
const t0 = performance.now()
const { value, cached } = await cachedGitRead<ChangedFile[]>({
key: `${worktreePath}\x00changed:${mode}`,
worktreePath,
fingerprintable: mode === 'branch',
baseRef: mode === 'branch' ? await getDefaultBaseRef(worktreePath) : null,
read: () => getChangedFilesImpl(worktreePath, mode)
})
const ms = performance.now() - t0
if (ms >= SLOW_CHANGED_FILES_MS) {
perfLog(
'changed-files',
`mode=${mode} path=${basename(worktreePath)} took=${ms.toFixed(0)}ms files=${value.length}${cached ? ' cached' : ''}`,
{ worktreePath, mode, ms: +ms.toFixed(1), fileCount: value.length, cached }
)
}
return value
}
async function getChangedFilesImpl(
worktreePath: string,
mode: ChangedFilesMode
): Promise<ChangedFile[]> {
const t0 = performance.now()
let walledExec = 0
let cumExec = 0
let outputBytes = 0
const execParts: number[] = []
let result: ChangedFile[] = []
if (mode === 'branch') {
const tBase = performance.now()
const base = await getDefaultBaseRef(worktreePath)
const baseMs = performance.now() - tBase
walledExec += baseMs
cumExec += baseMs
execParts.push(baseMs)
if (base !== 'HEAD') {
try {
const tA = performance.now()
const [diff, ns] = await Promise.all([
tracedExec(['diff', '--name-status', `${base}...HEAD`], { cwd: worktreePath }),
numstatExec(worktreePath, [`${base}...HEAD`])
])
walledExec += performance.now() - tA
cumExec += diff.execMs + ns.execMs
execParts.push(diff.execMs, ns.execMs)
outputBytes += diff.outputBytes + ns.outputBytes
const counts = parseNumstatZ(ns.stdout)
for (const line of diff.stdout.split('\n')) {
if (!line) continue
const parts = line.split('\t')
const code = parts[0]
const filePath = parts[parts.length - 1]
const file: ChangedFile = { path: filePath, status: mapNameStatus(code), staged: false }
applyCounts(file, counts.get(filePath))
result.push(file)
}
} catch {
result = []
}
}
} else {
try {
const tA = performance.now()
const [status, stagedNs, unstagedNs] = await Promise.all([
tracedExec(['status', '--porcelain', '-uall'], { cwd: worktreePath }),
numstatExec(worktreePath, ['--cached']),
numstatExec(worktreePath, [], 'diff-files')
])
walledExec += performance.now() - tA
cumExec += status.execMs + stagedNs.execMs + unstagedNs.execMs
execParts.push(status.execMs, stagedNs.execMs, unstagedNs.execMs)
outputBytes += status.outputBytes + stagedNs.outputBytes + unstagedNs.outputBytes
const stagedCounts = parseNumstatZ(stagedNs.stdout)
const unstagedCounts = parseNumstatZ(unstagedNs.stdout)
const seen = new Set<string>()
for (const line of status.stdout.split('\n')) {
if (!line) continue
const x = line[0]
const y = line[1]
const filePath = line.slice(3)
if (x !== ' ' && x !== '?') {
const fStatus =
x === 'A' ? 'added' : x === 'D' ? 'deleted' : x === 'R' ? 'renamed' : 'modified'
const file: ChangedFile = { path: filePath, status: fStatus, staged: true }
applyCounts(file, stagedCounts.get(destOf(filePath)))
result.push(file)
seen.add(filePath)
}
if (y !== ' ' && y !== '?') {
const fStatus = y === 'D' ? 'deleted' : 'modified'
if (!seen.has(filePath)) {
const file: ChangedFile = { path: filePath, status: fStatus, staged: false }
applyCounts(file, unstagedCounts.get(destOf(filePath)))
result.push(file)
seen.add(filePath)
}
}
if (x === '?' && y === '?') {
result.push({ path: filePath, status: 'untracked', staged: false })
}
}
} catch {
// status failure: fall through with empty result
}
}
logGitOp('getChangedFiles', { mode }, t0, walledExec, cumExec, outputBytes, execParts)
return result
}
export interface CommitMeta {
hash: string
shortHash: string
author: string
authorEmail: string
date: string
subject: string
body: string
}
export interface CommitDiff extends CommitMeta {
diff: string
}
/** Get a single commit's metadata (no diff). Cheap — one `git show -s`. */
export async function getCommitMeta(
worktreePath: string,
hash: string
): Promise<CommitMeta | null> {
if (!/^[0-9a-fA-F]{4,64}$/.test(hash)) return null
try {
const sep = '\x1f'
const end = '\x1e'
const { stdout: meta } = await execGitRead(
['show', '-s', `--pretty=format:%H${sep}%h${sep}%an${sep}%ae${sep}%aI${sep}%s${sep}%b${end}`, hash],
{ cwd: worktreePath }
)
const cleaned = meta.endsWith(end) ? meta.slice(0, -1) : meta
const [fullHash, shortHash, author, authorEmail, date, subject, body = ''] = cleaned.split(sep)
return { hash: fullHash, shortHash, author, authorEmail, date, subject, body }
} catch {
return null
}
}
/** Get a single commit's metadata + full diff. */
export async function getCommitDiff(
worktreePath: string,
hash: string
): Promise<CommitDiff | null> {
const meta = await getCommitMeta(worktreePath, hash)
if (!meta) return null
try {
const { stdout: diff } = await execGitRead(
['show', '--no-color', '--pretty=format:', hash],
{ cwd: worktreePath, maxBuffer: 32 * 1024 * 1024 }
)
return { ...meta, diff: diff.replace(/^\n+/, '') }
} catch {
return null
}
}
/** List full commit SHAs reachable from any ref, capped, for validating
* commit-SHA tokens printed in terminal output. `--all` covers local
* branches, tags, and remotes, so SHAs from `git log`, PR branches, etc.
* resolve; the cap keeps the payload bounded on large repos. */
export async function listRecentCommitShas(worktreePath: string): Promise<string[]> {
try {
const { stdout } = await execGitRead(
['rev-list', '--all', '--max-count=10000'],
{ cwd: worktreePath, maxBuffer: 16 * 1024 * 1024 }
)
return stdout.split('\n').filter((l) => l.length > 0)
} catch {
return []
}
}
export async function getCommitChangedFiles(
worktreePath: string,
hash: string
): Promise<ChangedFile[]> {
const t0 = performance.now()
const result = await getCommitChangedFilesImpl(worktreePath, hash)
const ms = performance.now() - t0
if (ms >= SLOW_CHANGED_FILES_MS) {
perfLog(
'changed-files',
`mode=commit path=${basename(worktreePath)} took=${ms.toFixed(0)}ms files=${result.length}`,
{ worktreePath, mode: 'commit', hash, ms: +ms.toFixed(1), fileCount: result.length }
)
}
return result
}
async function getCommitChangedFilesImpl(
worktreePath: string,
hash: string
): Promise<ChangedFile[]> {
if (!/^[0-9a-fA-F]{4,64}$/.test(hash)) return []
const t0 = performance.now()
let walledExec = 0
let cumExec = 0
let outputBytes = 0
const execParts: number[] = []
let result: ChangedFile[] = []
try {
const tA = performance.now()
const [nameStatus, ns] = await Promise.all([
tracedExec(['diff-tree', '--no-commit-id', '-r', '--name-status', hash], {
cwd: worktreePath
}),
numstatExec(worktreePath, [`${hash}^`, hash])
])
walledExec += performance.now() - tA
cumExec += nameStatus.execMs + ns.execMs
execParts.push(nameStatus.execMs, ns.execMs)
outputBytes += nameStatus.outputBytes + ns.outputBytes
const counts = parseNumstatZ(ns.stdout)
for (const line of nameStatus.stdout.split('\n')) {
if (!line) continue
const parts = line.split('\t')
const code = parts[0]
const filePath = parts[parts.length - 1]
const file: ChangedFile = { path: filePath, status: mapNameStatus(code), staged: false }
applyCounts(file, counts.get(filePath))
result.push(file)
}
} catch {
result = []
}
logGitOp('getCommitChangedFiles', { hash }, t0, walledExec, cumExec, outputBytes, execParts)
return result
}
export async function getCommitRangeChangedFiles(
worktreePath: string,
fromHash: string,
toHash: string
): Promise<ChangedFile[]> {
const t0 = performance.now()
if (
!/^[0-9a-fA-F]{4,64}$/.test(fromHash) ||
!/^[0-9a-fA-F]{4,64}$/.test(toHash)
) {
return []
}
let walledExec = 0
let cumExec = 0
let outputBytes = 0
const execParts: number[] = []
let result: ChangedFile[] = []
const range = `${fromHash}^..${toHash}`
try {
const tA = performance.now()
const [nameStatus, ns] = await Promise.all([
tracedExec(['diff', '--name-status', range], {
cwd: worktreePath
}),
numstatExec(worktreePath, [range])
])
walledExec += performance.now() - tA
cumExec += nameStatus.execMs + ns.execMs
execParts.push(nameStatus.execMs, ns.execMs)
outputBytes += nameStatus.outputBytes + ns.outputBytes
const counts = parseNumstatZ(ns.stdout)
for (const line of nameStatus.stdout.split('\n')) {
if (!line) continue
const parts = line.split('\t')
const code = parts[0]
const filePath = parts[parts.length - 1]
const file: ChangedFile = { path: filePath, status: mapNameStatus(code), staged: false }
applyCounts(file, counts.get(filePath))
result.push(file)
}
} catch {
result = []
}
logGitOp('getCommitRangeChangedFiles', { fromHash, toHash }, t0, walledExec, cumExec, outputBytes, execParts)
const rangeMs = performance.now() - t0
if (rangeMs >= SLOW_CHANGED_FILES_MS) {
perfLog(
'changed-files',
`mode=range path=${basename(worktreePath)} took=${rangeMs.toFixed(0)}ms files=${result.length}`,