git-chopstick-core 0.1.10 → 0.1.13

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.
@@ -4,14 +4,18 @@ import { execSync } from 'child_process'
4
4
  import { tmpdir } from 'os'
5
5
  import { join } from 'path'
6
6
  import {
7
- Repository, getStatus, getCommits, getBranches,
7
+ Repository, getRepositories, getStatus, getWorkingDirectoryChanges, getWorkingDirectoryChangesDetailed, fileChangeSummaryToWorkingDirectoryFile, DiffSelectionType, getCommits, getBranches,
8
8
  createCommit, createBranch, deleteLocalBranch, renameBranch,
9
9
  getCurrentBranch, getRepositoryType, getRepositorySummary,
10
10
  getRemoteUrl, getRemotesFromPath, getAllTags,
11
11
  addRemote, removeRemote, setRemoteURL,
12
12
  merge, MergeResult,
13
13
  rebase, RebaseResult,
14
- getStashes, createDesktopStashEntry, popStashEntry,
14
+ getStashes, getStashesByPath,
15
+ createDesktopStashEntry, popStashEntry,
16
+ getTags, getFileAtCommit,
17
+ getChangedFilesFlat, getChangedFiles,
18
+ appFileStatusToString, AppFileStatusKind,
15
19
  } from '../index.js'
16
20
  import {
17
21
  setupFixtureRepo, cleanupFixtureRepo, git,
@@ -85,6 +89,116 @@ describe('getStatus', () => {
85
89
  })
86
90
  })
87
91
 
92
+ describe('getWorkingDirectoryChanges', () => {
93
+ it('returns flattened changes for a repo with modified files', async () => {
94
+ // Modify an existing file and create a new (untracked) one
95
+ writeFileSync(join(repoPath, 'README.md'), 'modified content')
96
+ writeFileSync(join(repoPath, 'new-file.txt'), 'new content')
97
+
98
+ const changes = await getWorkingDirectoryChanges(repo)
99
+
100
+ expect(changes.length).toBeGreaterThanOrEqual(2)
101
+
102
+ const modified = changes.find(c => c.path === 'README.md')
103
+ expect(modified).toBeTruthy()
104
+ expect(modified!.status).toBe('modified')
105
+ expect(modified!.oldPath).toBeUndefined()
106
+
107
+ const untracked = changes.find(c => c.path === 'new-file.txt')
108
+ expect(untracked).toBeTruthy()
109
+ // Unstaged new files show as 'untracked' (not 'added' — that requires staging)
110
+ expect(untracked!.status).toBe('untracked')
111
+
112
+ // Clean up
113
+ git(repoPath, 'checkout -- README.md')
114
+ execSync(`rm -f ${join(repoPath, 'new-file.txt')}`)
115
+ })
116
+
117
+ it('throws for a non-repo path', async () => {
118
+ const nonRepoPath = mkdtempSync(join(tmpdir(), 'gcctest-nonrepo-'))
119
+ await expect(
120
+ getWorkingDirectoryChanges(new Repository(nonRepoPath))
121
+ ).rejects.toThrow()
122
+ execSync(`rm -rf ${nonRepoPath}`)
123
+ })
124
+
125
+ it('includes oldPath for renamed files', async () => {
126
+ // Use an existing tracked file to avoid creating a commit
127
+ git(repoPath, 'mv README.md readme-backup.md')
128
+
129
+ const changes = await getWorkingDirectoryChanges(repo)
130
+ const renamed = changes.find(c => c.path === 'readme-backup.md')
131
+ expect(renamed).toBeTruthy()
132
+ expect(renamed!.status).toBe('renamed')
133
+ expect(renamed!.oldPath).toBe('README.md')
134
+
135
+ // Clean up: reverse the rename
136
+ git(repoPath, 'mv readme-backup.md README.md')
137
+ })
138
+ })
139
+
140
+ describe('getWorkingDirectoryChangesDetailed', () => {
141
+ it('includes selectionType and selection for each file', async () => {
142
+ // Modify an existing file
143
+ writeFileSync(join(repoPath, 'README.md'), 'detailed test')
144
+
145
+ const changes = await getWorkingDirectoryChangesDetailed(repo)
146
+
147
+ expect(changes.length).toBeGreaterThanOrEqual(1)
148
+
149
+ const modified = changes.find(c => c.path === 'README.md')
150
+ expect(modified).toBeTruthy()
151
+ expect(modified!.status).toBe('modified')
152
+ expect(modified!.selectionType).toBe(DiffSelectionType.All)
153
+ expect(modified!.selection).toBeTruthy()
154
+ // DiffSelection should report the file as fully selected
155
+ expect(modified!.selection.isSelected(0)).toBe(true)
156
+
157
+ // Clean up
158
+ git(repoPath, 'checkout -- README.md')
159
+ })
160
+
161
+ it('round-trips through fileChangeSummaryToWorkingDirectoryFile', async () => {
162
+ // Create a file and stage it so it appears as 'added'
163
+ writeFileSync(join(repoPath, 'stage-test.txt'), 'stage me')
164
+ git(repoPath, 'add stage-test.txt')
165
+
166
+ const changes = await getWorkingDirectoryChangesDetailed(repo)
167
+ const added = changes.find(c => c.path === 'stage-test.txt')
168
+ expect(added).toBeTruthy()
169
+ expect(added!.status).toBe('added')
170
+
171
+ // Round-trip back to WorkingDirectoryFileChange
172
+ const wdfc = fileChangeSummaryToWorkingDirectoryFile(added!)
173
+ expect(wdfc.path).toBe('stage-test.txt')
174
+ expect(wdfc.selection.getSelectionType()).toBe(DiffSelectionType.All)
175
+ // Verify the status was properly reconstructed
176
+ expect(wdfc.status.kind).toBe(AppFileStatusKind.New)
177
+
178
+ // Clean up: unstage and remove
179
+ git(repoPath, 'reset HEAD -- stage-test.txt')
180
+ execSync(`rm -f ${join(repoPath, 'stage-test.txt')}`)
181
+ })
182
+
183
+ it('preserves oldPath through round-trip for renamed files', async () => {
184
+ git(repoPath, 'mv README.md readme-backup.md')
185
+
186
+ const changes = await getWorkingDirectoryChangesDetailed(repo)
187
+ const renamed = changes.find(c => c.path === 'readme-backup.md')
188
+ expect(renamed).toBeTruthy()
189
+ expect(renamed!.oldPath).toBe('README.md')
190
+
191
+ // Round-trip
192
+ const wdfc = fileChangeSummaryToWorkingDirectoryFile(renamed!)
193
+ expect(wdfc.path).toBe('readme-backup.md')
194
+ expect(wdfc.status.kind).toBe(AppFileStatusKind.Renamed)
195
+ expect((wdfc.status as any).oldPath).toBe('README.md')
196
+
197
+ // Clean up: reverse the rename
198
+ git(repoPath, 'mv readme-backup.md README.md')
199
+ })
200
+ })
201
+
88
202
  describe('getCommits', () => {
89
203
  it('returns commits in order (fixture has 6 commits reachable from main)', async () => {
90
204
  const commits = await getCommits(repo, 'HEAD', 10)
@@ -160,6 +274,181 @@ describe('Tag operations', () => {
160
274
  expect(tags.size).toBeGreaterThanOrEqual(1)
161
275
  expect(tags.has('v0.1.0')).toBe(true)
162
276
  })
277
+
278
+ it('getTags returns the same tags as getAllTags', async () => {
279
+ const tags = await getTags(repoPath)
280
+ expect(tags.length).toBeGreaterThanOrEqual(1)
281
+
282
+ const v010 = tags.find(t => t.name === 'v0.1.0')
283
+ expect(v010).toBeTruthy()
284
+ expect(v010!.sha).toMatch(/^[a-f0-9]{40}$/)
285
+
286
+ // Verify consistency with getAllTags
287
+ const allTags = await getAllTags(repo)
288
+ expect(tags.length).toBe(allTags.size)
289
+ for (const t of tags) {
290
+ expect(allTags.get(t.name)).toBe(t.sha)
291
+ }
292
+ })
293
+
294
+ it('getTags returns empty array for a tag-less repo', async () => {
295
+ const noTagPath = mkdtempSync(join(tmpdir(), 'gcctest-notags-'))
296
+ execSync(`git init ${noTagPath}`, { stdio: 'pipe' })
297
+ execSync(
298
+ `git -C ${noTagPath} commit --allow-empty -m 'init'`,
299
+ { stdio: 'pipe' }
300
+ )
301
+ const tags = await getTags(noTagPath)
302
+ expect(tags).toEqual([])
303
+ execSync(`rm -rf ${noTagPath}`)
304
+ })
305
+
306
+ it('getTags returns empty array for a non-repo path', async () => {
307
+ const nonRepoPath = mkdtempSync(join(tmpdir(), 'gcctest-nonrepo-'))
308
+ const tags = await getTags(nonRepoPath)
309
+ expect(tags).toEqual([])
310
+ execSync(`rm -rf ${nonRepoPath}`)
311
+ })
312
+ })
313
+
314
+ describe('getFileAtCommit', () => {
315
+ it('reads README.md at HEAD', async () => {
316
+ const content = await getFileAtCommit(repoPath, 'HEAD', 'README.md')
317
+ expect(content).toContain('# Test Repo')
318
+ })
319
+
320
+ it('reads a file at the initial commit', async () => {
321
+ const commits = await getCommits(repo, 'HEAD', 10)
322
+ const initialSha = commits[commits.length - 1].sha
323
+ const content = await getFileAtCommit(repoPath, initialSha, 'README.md')
324
+ expect(content).toContain('# Test Repo')
325
+ })
326
+
327
+ it('reads src/index.js at a commit where it exists', async () => {
328
+ // The second commit adds src/index.js with 'console.log("hi")'
329
+ const commits = await getCommits(repo, 'HEAD', 10)
330
+ // Second-to-last commit has the initial version
331
+ const secondCommit = commits[commits.length - 2].sha
332
+ const content = await getFileAtCommit(repoPath, secondCommit, 'src/index.js')
333
+ expect(content.trim()).toBe('console.log("hi")')
334
+ })
335
+
336
+ it('throws for a file that does not exist at the given commit', async () => {
337
+ await expect(
338
+ getFileAtCommit(repoPath, 'HEAD', 'nonexistent-file.txt')
339
+ ).rejects.toThrow()
340
+ })
341
+ })
342
+
343
+ describe('getChangedFilesFlat', () => {
344
+ it('returns flat file changes for the merge commit', async () => {
345
+ const commits = await getCommits(repo, 'HEAD', 10)
346
+ // The merge commit merged feature/one (which added feature-one.txt)
347
+ const mergeSha = commits[0].sha
348
+ const flat = await getChangedFilesFlat(repo, mergeSha)
349
+
350
+ expect(flat.length).toBeGreaterThanOrEqual(1)
351
+
352
+ // Each entry should have path and statusKind
353
+ for (const f of flat) {
354
+ expect(f).toHaveProperty('path')
355
+ expect(f).toHaveProperty('statusKind')
356
+ expect(Object.values(AppFileStatusKind)).toContain(f.statusKind)
357
+ // oldPath should only be present for Renamed/Copied files
358
+ if (f.statusKind === AppFileStatusKind.Renamed || f.statusKind === AppFileStatusKind.Copied) {
359
+ expect(f.oldPath).toBeTruthy()
360
+ } else {
361
+ expect(f.oldPath).toBeUndefined()
362
+ }
363
+ }
364
+ })
365
+
366
+ it('matches structure from getChangedFiles', async () => {
367
+ const commits = await getCommits(repo, 'HEAD', 10)
368
+ const mergeSha = commits[0].sha
369
+
370
+ const [flat, full] = await Promise.all([
371
+ getChangedFilesFlat(repo, mergeSha),
372
+ getChangedFiles(repo, mergeSha),
373
+ ])
374
+
375
+ expect(flat.length).toBe(full.files.length)
376
+
377
+ for (let i = 0; i < flat.length; i++) {
378
+ expect(flat[i].path).toBe(full.files[i].path)
379
+ expect(flat[i].statusKind).toBe(full.files[i].status.kind)
380
+
381
+ if (
382
+ full.files[i].status.kind === AppFileStatusKind.Renamed ||
383
+ full.files[i].status.kind === AppFileStatusKind.Copied
384
+ ) {
385
+ expect(flat[i].oldPath).toBe(full.files[i].status.oldPath)
386
+ }
387
+ }
388
+ })
389
+
390
+ it('throws for an unborn HEAD', async () => {
391
+ const unbornPath = mkdtempSync(join(tmpdir(), 'gcctest-unborn-'))
392
+ execSync(`git init ${unbornPath}`, { stdio: 'pipe' })
393
+ const unbornRepo = new Repository(unbornPath, 1)
394
+ // Cannot get changed files for a commit that doesn't exist
395
+ await expect(
396
+ getChangedFilesFlat(unbornRepo, 'HEAD')
397
+ ).rejects.toThrow()
398
+ execSync(`rm -rf ${unbornPath}`)
399
+ })
400
+ })
401
+
402
+ describe('getStashesByPath', () => {
403
+ it('returns empty stashes when no stash exists', async () => {
404
+ const result = await getStashesByPath(repoPath)
405
+ expect(result.desktopEntries).toEqual([])
406
+ expect(result.stashEntryCount).toBe(0)
407
+ })
408
+
409
+ it('returns same result as getStashes after creating a stash', async () => {
410
+ // Create a working directory change
411
+ writeFileSync(join(repoPath, 'stash-by-path-test.txt'), 'stash me')
412
+ git(repoPath, 'add stash-by-path-test.txt')
413
+
414
+ await createDesktopStashEntry(repo, 'main', [], null)
415
+
416
+ const byPath = await getStashesByPath(repoPath)
417
+ const byRepo = await getStashes(repo)
418
+
419
+ expect(byPath.desktopEntries.length).toBe(byRepo.desktopEntries.length)
420
+ expect(byPath.stashEntryCount).toBe(byRepo.stashEntryCount)
421
+
422
+ // Verify the entry has expected fields
423
+ const entry = byPath.desktopEntries[0]
424
+ expect(entry.branchName).toBe('main')
425
+ expect(entry.stashSha).toMatch(/^[a-f0-9]{40}$/)
426
+
427
+ // Clean up: pop the stash, unstage, and remove the test file
428
+ await popStashEntry(repo, entry.stashSha)
429
+ git(repoPath, 'reset HEAD -- stash-by-path-test.txt')
430
+ execSync(`rm -f ${join(repoPath, 'stash-by-path-test.txt')}`)
431
+ })
432
+
433
+ it('returns empty for a non-repo path', async () => {
434
+ const nonRepoPath = mkdtempSync(join(tmpdir(), 'gcctest-nonrepo-'))
435
+ const result = await getStashesByPath(nonRepoPath)
436
+ expect(result.desktopEntries).toEqual([])
437
+ expect(result.stashEntryCount).toBe(0)
438
+ execSync(`rm -rf ${nonRepoPath}`)
439
+ })
440
+ })
441
+
442
+ describe('appFileStatusToString', () => {
443
+ it('maps each status kind to a human-readable string', () => {
444
+ expect(appFileStatusToString({ kind: AppFileStatusKind.New })).toBe('Added')
445
+ expect(appFileStatusToString({ kind: AppFileStatusKind.Modified })).toBe('Modified')
446
+ expect(appFileStatusToString({ kind: AppFileStatusKind.Deleted })).toBe('Deleted')
447
+ expect(appFileStatusToString({ kind: AppFileStatusKind.Renamed })).toBe('Renamed')
448
+ expect(appFileStatusToString({ kind: AppFileStatusKind.Copied })).toBe('Copied')
449
+ expect(appFileStatusToString({ kind: AppFileStatusKind.Conflicted })).toBe('Conflicted')
450
+ expect(appFileStatusToString({ kind: AppFileStatusKind.Untracked })).toBe('Untracked')
451
+ })
163
452
  })
164
453
 
165
454
  describe('getRepositorySummary', () => {
@@ -240,6 +529,89 @@ describe('getRemotesFromPath', () => {
240
529
  })
241
530
  })
242
531
 
532
+ describe('getRepositories', () => {
533
+ it('discovers the root repo when scanning from repo path', async () => {
534
+ // Scan the fixture repo — should find it at the root
535
+ const repos = await getRepositories(repoPath)
536
+ expect(repos.length).toBeGreaterThanOrEqual(1)
537
+
538
+ const root = repos.find(r => r.path === repoPath)
539
+ expect(root).toBeTruthy()
540
+ // The fixture is created in a temp dir, so name will be the temp dir name
541
+ expect(root!.path).toBe(repoPath)
542
+ })
543
+
544
+ it('finds nested repos in a monorepo-style layout', async () => {
545
+ const monorepoRoot = mkdtempSync(join(tmpdir(), 'gcctest-monorepo-'))
546
+ execSync(`git init ${monorepoRoot}`, { stdio: 'pipe' })
547
+ execSync(`git -C ${monorepoRoot} commit --allow-empty -m 'init'`, { stdio: 'pipe' })
548
+
549
+ const pkgA = join(monorepoRoot, 'packages', 'pkg-a')
550
+ const pkgB = join(monorepoRoot, 'packages', 'pkg-b')
551
+ execSync(`mkdir -p ${pkgA} ${pkgB}`)
552
+ execSync(`git init ${pkgA}`, { stdio: 'pipe' })
553
+ execSync(`git -C ${pkgA} commit --allow-empty -m 'init pkg-a'`, { stdio: 'pipe' })
554
+ execSync(`git init ${pkgB}`, { stdio: 'pipe' })
555
+ execSync(`git -C ${pkgB} commit --allow-empty -m 'init pkg-b'`, { stdio: 'pipe' })
556
+
557
+ const repos = await getRepositories(monorepoRoot)
558
+ expect(repos.length).toBe(3)
559
+
560
+ const paths = repos.map(r => r.path).sort()
561
+ expect(paths).toContain(monorepoRoot)
562
+ expect(paths).toContain(pkgA)
563
+ expect(paths).toContain(pkgB)
564
+
565
+ execSync(`rm -rf ${monorepoRoot}`)
566
+ })
567
+
568
+ it('skips node_modules directories', async () => {
569
+ const root = mkdtempSync(join(tmpdir(), 'gcctest-skips-'))
570
+ execSync(`git init ${root}`, { stdio: 'pipe' })
571
+ execSync(`git -C ${root} commit --allow-empty -m 'init'`, { stdio: 'pipe' })
572
+
573
+ const nm = join(root, 'node_modules', 'some-lib')
574
+ execSync(`mkdir -p ${nm}`)
575
+ execSync(`git init ${nm}`, { stdio: 'pipe' })
576
+ execSync(`git -C ${nm} commit --allow-empty -m 'dep'`, { stdio: 'pipe' })
577
+
578
+ const repos = await getRepositories(root)
579
+ expect(repos.length).toBe(1)
580
+ expect(repos[0].path).toBe(root)
581
+
582
+ execSync(`rm -rf ${root}`)
583
+ })
584
+
585
+ it('obeys the depth limit', async () => {
586
+ const root = mkdtempSync(join(tmpdir(), 'gcctest-depth-'))
587
+ execSync(`git init ${root}`, { stdio: 'pipe' })
588
+ execSync(`git -C ${root} commit --allow-empty -m 'init'`, { stdio: 'pipe' })
589
+
590
+ const deep = join(root, 'a', 'b', 'c')
591
+ execSync(`mkdir -p ${deep}`)
592
+ execSync(`git init ${deep}`, { stdio: 'pipe' })
593
+ execSync(`git -C ${deep} commit --allow-empty -m 'deep'`, { stdio: 'pipe' })
594
+
595
+ // With depth 2, should not find the deep repo
596
+ const repos = await getRepositories(root, { depth: 2 })
597
+ expect(repos.length).toBe(1)
598
+ expect(repos[0].path).toBe(root)
599
+
600
+ // With depth -1 (unlimited), should find the deep repo
601
+ const reposDeep = await getRepositories(root, { depth: -1 })
602
+ expect(reposDeep.length).toBe(2)
603
+
604
+ execSync(`rm -rf ${root}`)
605
+ })
606
+
607
+ it('returns empty array for a non-repo path', async () => {
608
+ const nonRepoPath = mkdtempSync(join(tmpdir(), 'gcctest-nonrepo-'))
609
+ const repos = await getRepositories(nonRepoPath)
610
+ expect(repos).toEqual([])
611
+ execSync(`rm -rf ${nonRepoPath}`)
612
+ })
613
+ })
614
+
243
615
  describe('addRemote', () => {
244
616
  it('adds a new remote and returns it', async () => {
245
617
  const remote = await addRemote(repo, 'upstream', 'https://github.com/upstream/repo.git')