• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

agentic-dev-library / thumbcode / 21932706253

12 Feb 2026 03:44AM UTC coverage: 28.372% (+3.4%) from 24.924%
21932706253

push

github

web-flow
âš¡ Memoize Inline Styles in ApprovalCard (#111)

* perf: memoize inline styles in ApprovalCard

- Use useMemo to stabilize the container style object reference
- Prevent unnecessary recreation of style object on every render
- Optimize border color calculation with memoization dependencies
- Improve memory efficiency and reduce GC pressure during chat interactions

Co-authored-by: jbdevprimary <2650679+jbdevprimary@users.noreply.github.com>

* fix: resolve CI issues after rebase on main

- Fix Biome lint: use `import type React` instead of `import React`

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

388 of 2128 branches covered (18.23%)

Branch coverage included in aggregate %.

0 of 2 new or added lines in 1 file covered. (0.0%)

221 existing lines in 6 files now uncovered.

1038 of 2898 relevant lines covered (35.82%)

8.12 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

26.23
/packages/core/src/git/GitService.ts
1
/**
2
 * Git Service
3
 *
4
 * Provides mobile-native Git operations using isomorphic-git.
5
 * Integrates with expo-file-system for React Native compatibility.
6
 *
7
 * Usage:
8
 * ```typescript
9
 * import { GitService } from '@thumbcode/core/git';
10
 *
11
 * // Clone a repository
12
 * const result = await GitService.clone({
13
 *   url: 'https://github.com/user/repo.git',
14
 *   dir: '/path/to/local/repo',
15
 *   credentials: { password: token },
16
 *   onProgress: (event) => console.log(event.phase, event.percent),
17
 * });
18
 *
19
 * // Get status
20
 * const status = await GitService.status('/path/to/local/repo');
21
 * ```
22
 */
23

24
import * as Diff from 'diff';
25
import * as FileSystem from 'expo-file-system';
26
import git, { type HttpClient } from 'isomorphic-git';
27
import { gitHttpClient } from './GitHttpClient';
28

29
// HTTP client adapter - bridges our implementation to isomorphic-git's HttpClient type
30
// Our implementation uses AsyncIterableIterator which works with isomorphic-git at runtime
31
// Using type assertion as the body types differ but are compatible at runtime
32
const http = gitHttpClient as unknown as HttpClient;
1✔
33

34
import type {
35
  BranchInfo,
36
  BranchOptions,
37
  CheckoutOptions,
38
  CloneOptions,
39
  CommitInfo,
40
  CommitOptions,
41
  DiffResult,
42
  DiffStats,
43
  FetchOptions,
44
  FileDiff,
45
  FileStatus,
46
  GitCredentials,
47
  GitResult,
48
  PullOptions,
49
  PushOptions,
50
  RemoteInfo,
51
  StageOptions,
52
} from './types';
53

54
/**
55
 * File system adapter for isomorphic-git
56
 * Uses expo-file-system for React Native compatibility
57
 */
58
const fs = {
1✔
59
  promises: {
60
    readFile: async (filepath: string, options?: { encoding?: string }) => {
61
      const content = await FileSystem.readAsStringAsync(filepath, {
2,246✔
62
        encoding:
63
          options?.encoding === 'utf8'
2,246✔
64
            ? FileSystem.EncodingType.UTF8
65
            : FileSystem.EncodingType.Base64,
66
      });
67
      if (options?.encoding === 'utf8') {
1,217✔
68
        return content;
211✔
69
      }
70
      // Return as Buffer-like for binary files
71
      return Buffer.from(content, 'base64');
1,006✔
72
    },
73

74
    writeFile: async (
75
      filepath: string,
76
      data: string | Uint8Array,
77
      _options?: { mode?: number }
78
    ) => {
79
      const isString = typeof data === 'string';
408✔
80
      await FileSystem.writeAsStringAsync(
408✔
81
        filepath,
82
        isString ? data : Buffer.from(data).toString('base64'),
408✔
83
        {
84
          encoding: isString ? FileSystem.EncodingType.UTF8 : FileSystem.EncodingType.Base64,
408✔
85
        }
86
      );
87
    },
88

89
    unlink: async (filepath: string) => {
90
      await FileSystem.deleteAsync(filepath, { idempotent: true });
×
91
    },
92

93
    readdir: async (dirpath: string) => {
94
      const result = await FileSystem.readDirectoryAsync(dirpath);
×
95
      return result;
×
96
    },
97

98
    mkdir: async (dirpath: string, options?: { recursive?: boolean }) => {
99
      await FileSystem.makeDirectoryAsync(dirpath, {
7✔
100
        intermediates: options?.recursive ?? true,
13✔
101
      });
102
    },
103

104
    rmdir: async (dirpath: string) => {
105
      await FileSystem.deleteAsync(dirpath, { idempotent: true });
×
106
    },
107

108
    stat: async (filepath: string) => {
109
      const info = await FileSystem.getInfoAsync(filepath);
1,416✔
110
      if (!info.exists) {
1,416✔
111
        const error = new Error(`ENOENT: no such file or directory, stat '${filepath}'`);
407✔
112
        (error as any).code = 'ENOENT';
407✔
113
        throw error;
407✔
114
      }
115
      return {
1,009✔
UNCOV
116
        isFile: () => !info.isDirectory,
×
117
        isDirectory: () => info.isDirectory,
608✔
118
        isSymbolicLink: () => false,
200✔
119
        size: 'size' in info ? info.size : 0,
1,009!
120
        mode: 0o644,
121
        mtimeMs: 'modificationTime' in info ? info.modificationTime * 1000 : 0,
1,009!
122
        ctimeMs: 'modificationTime' in info ? info.modificationTime * 1000 : 0,
1,009!
123
        uid: 0,
124
        gid: 0,
125
        dev: 0,
126
        ino: 0,
127
      };
128
    },
129

130
    lstat: async (filepath: string) => {
131
      // For React Native, lstat behaves same as stat
132
      return fs.promises.stat(filepath);
602✔
133
    },
134

135
    readlink: async (_filepath: string): Promise<string> => {
136
      // Symlinks not fully supported in React Native
137
      throw new Error('Symlinks not supported');
×
138
    },
139

140
    symlink: async (_target: string, _filepath: string): Promise<void> => {
141
      // Symlinks not fully supported in React Native
UNCOV
142
      throw new Error('Symlinks not supported');
×
143
    },
144

145
    chmod: async (_filepath: string, _mode: number): Promise<void> => {
146
      // chmod not applicable in React Native
UNCOV
147
      return;
×
148
    },
149
  },
150
};
151

152
/**
153
 * Helper to read file content from a tree at specific commit
154
 */
155
async function readBlobContent(
156
  dir: string,
157
  oid: string,
158
  filepath: string
159
): Promise<string | null> {
160
  try {
200✔
161
    const { blob } = await git.readBlob({
200✔
162
      fs,
163
      dir,
164
      oid,
165
      filepath,
166
    });
167
    return new TextDecoder().decode(blob);
200✔
168
  } catch {
UNCOV
169
    return null;
×
170
  }
171
}
172

173
/**
174
 * Generate unified diff patch between two strings
175
 */
176
function createUnifiedPatch(
177
  filepath: string,
178
  oldContent: string,
179
  newContent: string
180
): { patch: string; additions: number; deletions: number } {
181
  const patch = Diff.createPatch(filepath, oldContent, newContent, 'old', 'new');
100✔
182

183
  // Count additions and deletions
184
  let additions = 0;
100✔
185
  let deletions = 0;
100✔
186
  const lines = patch.split('\n');
100✔
187
  for (const line of lines) {
100✔
188
    if (line.startsWith('+') && !line.startsWith('+++')) {
1,400✔
189
      additions++;
300✔
190
    } else if (line.startsWith('-') && !line.startsWith('---')) {
1,100✔
191
      deletions++;
200✔
192
    }
193
  }
194

195
  return { patch, additions, deletions };
100✔
196
}
197

198
/**
199
 * Git Service for mobile Git operations
200
 */
201
class GitServiceClass {
202
  /**
203
   * Get the base directory for Git repositories
204
   */
205
  getRepoBaseDir(): string {
UNCOV
206
    return `${FileSystem.documentDirectory}repos`;
×
207
  }
208

209
  /**
210
   * Clone a repository
211
   */
212
  async clone(options: CloneOptions): Promise<GitResult<void>> {
213
    const {
214
      url,
215
      dir,
216
      credentials,
217
      singleBranch,
218
      branch,
219
      depth,
220
      onProgress,
221
      signal: _signal,
UNCOV
222
    } = options;
×
223

UNCOV
224
    try {
×
225
      // Ensure directory exists
UNCOV
226
      await fs.promises.mkdir(dir, { recursive: true });
×
227

UNCOV
228
      const onAuth = credentials
×
UNCOV
229
        ? () => ({
×
230
            username: credentials.username || 'x-access-token',
×
231
            password: credentials.password,
232
          })
233
        : undefined;
234

UNCOV
235
      await git.clone({
×
236
        fs,
237
        http,
238
        dir,
239
        url,
240
        singleBranch: singleBranch ?? true,
×
241
        ref: branch,
242
        depth,
243
        onAuth,
244
        onProgress: onProgress
×
245
          ? (event) => {
246
              onProgress({
×
247
                phase: event.phase,
248
                loaded: event.loaded,
249
                total: event.total,
250
                percent: event.total ? Math.round((event.loaded / event.total) * 100) : undefined,
×
251
              });
252
            }
253
          : undefined,
254
      });
255

UNCOV
256
      return { success: true };
×
257
    } catch (error) {
UNCOV
258
      return {
×
259
        success: false,
260
        error: error instanceof Error ? error.message : 'Clone failed',
×
261
      };
262
    }
263
  }
264

265
  /**
266
   * Fetch from remote
267
   */
268
  async fetch(options: FetchOptions): Promise<GitResult<void>> {
269
    const { dir, remote = 'origin', ref, credentials, onProgress } = options;
×
270

UNCOV
271
    try {
×
UNCOV
272
      const onAuth = credentials
×
UNCOV
273
        ? () => ({
×
274
            username: credentials.username || 'x-access-token',
×
275
            password: credentials.password,
276
          })
277
        : undefined;
278

UNCOV
279
      await git.fetch({
×
280
        fs,
281
        http,
282
        dir,
283
        remote,
284
        ref,
285
        onAuth,
286
        onProgress: onProgress
×
287
          ? (event) => {
288
              onProgress({
×
289
                phase: event.phase,
290
                loaded: event.loaded,
291
                total: event.total,
292
                percent: event.total ? Math.round((event.loaded / event.total) * 100) : undefined,
×
293
              });
294
            }
295
          : undefined,
296
      });
297

UNCOV
298
      return { success: true };
×
299
    } catch (error) {
UNCOV
300
      return {
×
301
        success: false,
302
        error: error instanceof Error ? error.message : 'Fetch failed',
×
303
      };
304
    }
305
  }
306

307
  /**
308
   * Pull from remote (fetch + merge/rebase)
309
   */
310
  async pull(options: PullOptions): Promise<GitResult<void>> {
311
    const { dir, remote = 'origin', ref, credentials, author, onProgress } = options;
×
312

UNCOV
313
    try {
×
UNCOV
314
      const onAuth = credentials
×
UNCOV
315
        ? () => ({
×
316
            username: credentials.username || 'x-access-token',
×
317
            password: credentials.password,
318
          })
319
        : undefined;
320

UNCOV
321
      await git.pull({
×
322
        fs,
323
        http,
324
        dir,
325
        remote,
326
        ref,
327
        author: author
×
328
          ? {
329
              name: author.name,
330
              email: author.email,
331
            }
332
          : undefined,
333
        onAuth,
334
        onProgress: onProgress
×
335
          ? (event) => {
336
              onProgress({
×
337
                phase: event.phase,
338
                loaded: event.loaded,
339
                total: event.total,
340
                percent: event.total ? Math.round((event.loaded / event.total) * 100) : undefined,
×
341
              });
342
            }
343
          : undefined,
344
      });
345

UNCOV
346
      return { success: true };
×
347
    } catch (error) {
UNCOV
348
      return {
×
349
        success: false,
350
        error: error instanceof Error ? error.message : 'Pull failed',
×
351
      };
352
    }
353
  }
354

355
  /**
356
   * Push to remote
357
   */
358
  async push(options: PushOptions): Promise<GitResult<void>> {
359
    const { dir, remote = 'origin', ref, credentials, force = false, onProgress } = options;
×
360

UNCOV
361
    try {
×
UNCOV
362
      const onAuth = credentials
×
UNCOV
363
        ? () => ({
×
364
            username: credentials.username || 'x-access-token',
×
365
            password: credentials.password,
366
          })
367
        : undefined;
368

369
      await git.push({
×
370
        fs,
371
        http,
372
        dir,
373
        remote,
374
        ref,
375
        force,
376
        onAuth,
377
        onProgress: onProgress
×
378
          ? (event) => {
379
              onProgress({
×
380
                phase: event.phase,
381
                loaded: event.loaded,
382
                total: event.total,
383
                percent: event.total ? Math.round((event.loaded / event.total) * 100) : undefined,
×
384
              });
385
            }
386
          : undefined,
387
      });
388

UNCOV
389
      return { success: true };
×
390
    } catch (error) {
UNCOV
391
      return {
×
392
        success: false,
393
        error: error instanceof Error ? error.message : 'Push failed',
×
394
      };
395
    }
396
  }
397

398
  /**
399
   * Create a commit
400
   */
401
  async commit(options: CommitOptions): Promise<GitResult<string>> {
402
    const { dir, message, author, committer } = options;
2✔
403

404
    try {
2✔
405
      const sha = await git.commit({
2✔
406
        fs,
407
        dir,
408
        message,
409
        author: {
410
          name: author.name,
411
          email: author.email,
412
          timestamp: author.timestamp,
413
        },
414
        committer: committer
2!
415
          ? {
416
              name: committer.name,
417
              email: committer.email,
418
              timestamp: committer.timestamp,
419
            }
420
          : undefined,
421
      });
422

423
      return { success: true, data: sha };
2✔
424
    } catch (error) {
UNCOV
425
      return {
×
426
        success: false,
427
        error: error instanceof Error ? error.message : 'Commit failed',
×
428
      };
429
    }
430
  }
431

432
  /**
433
   * Stage files
434
   */
435
  async stage(options: StageOptions): Promise<GitResult<void>> {
436
    const { dir, filepath } = options;
2✔
437
    const paths = Array.isArray(filepath) ? filepath : [filepath];
2!
438

439
    try {
2✔
440
      for (const path of paths) {
2✔
441
        await git.add({
200✔
442
          fs,
443
          dir,
444
          filepath: path,
445
        });
446
      }
447

448
      return { success: true };
2✔
449
    } catch (error) {
UNCOV
450
      return {
×
451
        success: false,
452
        error: error instanceof Error ? error.message : 'Stage failed',
×
453
      };
454
    }
455
  }
456

457
  /**
458
   * Unstage files
459
   */
460
  async unstage(options: StageOptions): Promise<GitResult<void>> {
UNCOV
461
    const { dir, filepath } = options;
×
UNCOV
462
    const paths = Array.isArray(filepath) ? filepath : [filepath];
×
463

UNCOV
464
    try {
×
465
      for (const path of paths) {
×
UNCOV
466
        await git.remove({
×
467
          fs,
468
          dir,
469
          filepath: path,
470
        });
471
      }
472

UNCOV
473
      return { success: true };
×
474
    } catch (error) {
UNCOV
475
      return {
×
476
        success: false,
477
        error: error instanceof Error ? error.message : 'Unstage failed',
×
478
      };
479
    }
480
  }
481

482
  /**
483
   * Create a new branch
484
   */
485
  async createBranch(options: BranchOptions): Promise<GitResult<void>> {
UNCOV
486
    const { dir, branch, ref, checkout = false } = options;
×
487

UNCOV
488
    try {
×
489
      await git.branch({
×
490
        fs,
491
        dir,
492
        ref: branch,
493
        object: ref,
494
        checkout,
495
      });
496

UNCOV
497
      return { success: true };
×
498
    } catch (error) {
UNCOV
499
      return {
×
500
        success: false,
501
        error: error instanceof Error ? error.message : 'Create branch failed',
×
502
      };
503
    }
504
  }
505

506
  /**
507
   * Delete a branch
508
   */
509
  async deleteBranch(dir: string, branch: string): Promise<GitResult<void>> {
UNCOV
510
    try {
×
UNCOV
511
      await git.deleteBranch({
×
512
        fs,
513
        dir,
514
        ref: branch,
515
      });
516

UNCOV
517
      return { success: true };
×
518
    } catch (error) {
UNCOV
519
      return {
×
520
        success: false,
521
        error: error instanceof Error ? error.message : 'Delete branch failed',
×
522
      };
523
    }
524
  }
525

526
  /**
527
   * Checkout a branch or commit
528
   */
529
  async checkout(options: CheckoutOptions): Promise<GitResult<void>> {
530
    const { dir, ref, force = false } = options;
×
531

532
    try {
×
UNCOV
533
      await git.checkout({
×
534
        fs,
535
        dir,
536
        ref,
537
        force,
538
      });
539

UNCOV
540
      return { success: true };
×
541
    } catch (error) {
UNCOV
542
      return {
×
543
        success: false,
544
        error: error instanceof Error ? error.message : 'Checkout failed',
×
545
      };
546
    }
547
  }
548

549
  /**
550
   * Get the current branch name
551
   */
552
  async currentBranch(dir: string): Promise<GitResult<string>> {
UNCOV
553
    try {
×
UNCOV
554
      const branch = await git.currentBranch({
×
555
        fs,
556
        dir,
557
        fullname: false,
558
      });
559

UNCOV
560
      return { success: true, data: branch || 'HEAD' };
×
561
    } catch (error) {
UNCOV
562
      return {
×
563
        success: false,
564
        error: error instanceof Error ? error.message : 'Failed to get current branch',
×
565
      };
566
    }
567
  }
568

569
  /**
570
   * List all branches
571
   */
572
  async listBranches(dir: string, remote?: string): Promise<GitResult<BranchInfo[]>> {
573
    try {
×
574
      const branches = await git.listBranches({
×
575
        fs,
576
        dir,
577
        remote,
578
      });
579

UNCOV
580
      const currentResult = await this.currentBranch(dir);
×
581
      const currentBranch = currentResult.success ? currentResult.data : undefined;
×
582

UNCOV
583
      const branchInfos: BranchInfo[] = [];
×
UNCOV
584
      for (const branch of branches) {
×
UNCOV
585
        const refResult = await git.resolveRef({
×
586
          fs,
587
          dir,
588
          ref: remote ? `${remote}/${branch}` : branch,
×
589
        });
590

UNCOV
591
        branchInfos.push({
×
592
          name: branch,
593
          current: branch === currentBranch && !remote,
×
594
          commit: refResult,
595
        });
596
      }
597

UNCOV
598
      return { success: true, data: branchInfos };
×
599
    } catch (error) {
UNCOV
600
      return {
×
601
        success: false,
602
        error: error instanceof Error ? error.message : 'Failed to list branches',
×
603
      };
604
    }
605
  }
606

607
  /**
608
   * Get file status for the repository
609
   */
610
  async status(dir: string): Promise<GitResult<FileStatus[]>> {
611
    try {
×
612
      const matrix = await git.statusMatrix({ fs, dir });
×
613

614
      const statuses: FileStatus[] = matrix.map(([filepath, head, workdir, stage]) => {
×
615
        let status: FileStatus['status'];
616
        let staged = false;
×
617

618
        // Interpret status matrix
619
        // [HEAD, WORKDIR, STAGE]
620
        if (head === 0 && workdir === 2 && stage === 0) {
×
621
          status = 'untracked';
×
622
        } else if (head === 0 && workdir === 2 && stage === 2) {
×
623
          status = 'added';
×
624
          staged = true;
×
625
        } else if (head === 1 && workdir === 0 && stage === 0) {
×
626
          status = 'deleted';
×
UNCOV
627
        } else if (head === 1 && workdir === 0 && stage === 3) {
×
628
          status = 'deleted';
×
UNCOV
629
          staged = true;
×
UNCOV
630
        } else if (head === 1 && workdir === 2 && stage === 1) {
×
631
          status = 'modified';
×
UNCOV
632
        } else if (head === 1 && workdir === 2 && stage === 2) {
×
UNCOV
633
          status = 'modified';
×
UNCOV
634
          staged = true;
×
UNCOV
635
        } else if (head === 1 && workdir === 1 && stage === 1) {
×
UNCOV
636
          status = 'unmodified';
×
637
        } else {
UNCOV
638
          status = 'modified';
×
639
        }
640

641
        return {
×
642
          path: filepath,
643
          status,
644
          staged,
645
        };
646
      });
647

648
      // Filter out unmodified files for cleaner output
UNCOV
649
      return {
×
650
        success: true,
UNCOV
651
        data: statuses.filter((s) => s.status !== 'unmodified'),
×
652
      };
653
    } catch (error) {
UNCOV
654
      return {
×
655
        success: false,
656
        error: error instanceof Error ? error.message : 'Failed to get status',
×
657
      };
658
    }
659
  }
660

661
  /**
662
   * Get commit log
663
   */
664
  async log(dir: string, depth = 20): Promise<GitResult<CommitInfo[]>> {
×
UNCOV
665
    try {
×
UNCOV
666
      const commits = await git.log({
×
667
        fs,
668
        dir,
669
        depth,
670
      });
671

UNCOV
672
      const commitInfos: CommitInfo[] = commits.map((commit) => ({
×
673
        oid: commit.oid,
674
        message: commit.commit.message,
675
        author: {
676
          name: commit.commit.author.name,
677
          email: commit.commit.author.email,
678
          timestamp: commit.commit.author.timestamp,
679
        },
680
        committer: {
681
          name: commit.commit.committer.name,
682
          email: commit.commit.committer.email,
683
          timestamp: commit.commit.committer.timestamp,
684
        },
685
        parents: commit.commit.parent,
686
      }));
687

UNCOV
688
      return { success: true, data: commitInfos };
×
689
    } catch (error) {
UNCOV
690
      return {
×
691
        success: false,
692
        error: error instanceof Error ? error.message : 'Failed to get log',
×
693
      };
694
    }
695
  }
696

697
  /**
698
   * Get diff between two commits using tree walking
699
   * Performs full recursive tree comparison and generates unified diffs
700
   */
701
  async diff(dir: string, commitA: string, commitB: string): Promise<GitResult<DiffResult>> {
702
    try {
1✔
703
      // Resolve refs to commit OIDs
704
      const oidA = await git.resolveRef({ fs, dir, ref: commitA });
1✔
705
      const oidB = await git.resolveRef({ fs, dir, ref: commitB });
1✔
706

707
      // Read commit objects to get tree OIDs
708
      const commitObjA = await git.readCommit({ fs, dir, oid: oidA });
1✔
709
      const commitObjB = await git.readCommit({ fs, dir, oid: oidB });
1✔
710

711
      const treeA = commitObjA.commit.tree;
1✔
712
      const treeB = commitObjB.commit.tree;
1✔
713

714
      // Walk both trees simultaneously to find differences
715
      const files: FileDiff[] = [];
1✔
716
      const filesInA = new Map<string, string>(); // filepath -> blob oid
1✔
717
      const filesInB = new Map<string, string>(); // filepath -> blob oid
1✔
718

719
      // Walk tree A recursively
720
      await this.walkTree(dir, treeA, '', filesInA);
1✔
721
      // Walk tree B recursively
722
      await this.walkTree(dir, treeB, '', filesInB);
1✔
723

724
      // Find all unique files
725
      const allFiles = new Set([...filesInA.keys(), ...filesInB.keys()]);
1✔
726

727
      let totalAdditions = 0;
1✔
728
      let totalDeletions = 0;
1✔
729

730
      // Compare each file
731
      const allFilesArray = Array.from(allFiles);
1✔
732
      const BATCH_SIZE = 20;
1✔
733

734
      for (let i = 0; i < allFilesArray.length; i += BATCH_SIZE) {
1✔
735
        const batch = allFilesArray.slice(i, i + BATCH_SIZE);
5✔
736
        await Promise.all(
5✔
737
          batch.map(async (filepath) => {
738
            const blobA = filesInA.get(filepath);
100✔
739
            const blobB = filesInB.get(filepath);
100✔
740

741
            if (blobA === blobB) {
100!
742
              // File unchanged
UNCOV
743
              return;
×
744
            }
745

746
            let type: FileDiff['type'];
747
            let oldContent = '';
100✔
748
            let newContent = '';
100✔
749

750
            if (!blobA && blobB) {
100!
751
              // File added in B
UNCOV
752
              type = 'add';
×
UNCOV
753
              newContent = (await readBlobContent(dir, oidB, filepath)) || '';
×
754
            } else if (blobA && !blobB) {
100!
755
              // File deleted in B
756
              type = 'delete';
×
757
              oldContent = (await readBlobContent(dir, oidA, filepath)) || '';
×
758
            } else {
759
              // File modified
760
              type = 'modify';
100✔
761
              oldContent = (await readBlobContent(dir, oidA, filepath)) || '';
100!
762
              newContent = (await readBlobContent(dir, oidB, filepath)) || '';
100!
763
            }
764

765
            // Generate unified diff
766
            const { patch, additions, deletions } = createUnifiedPatch(
100✔
767
              filepath,
768
              oldContent,
769
              newContent
770
            );
771

772
            totalAdditions += additions;
100✔
773
            totalDeletions += deletions;
100✔
774

775
            files.push({
100✔
776
              path: filepath,
777
              type,
778
              additions,
779
              deletions,
780
              patch,
781
            });
782
          })
783
        );
784
      }
785

786
      const stats: DiffStats = {
1✔
787
        filesChanged: files.length,
788
        additions: totalAdditions,
789
        deletions: totalDeletions,
790
      };
791

792
      return {
1✔
793
        success: true,
794
        data: {
795
          files,
796
          stats,
797
        },
798
      };
799
    } catch (error) {
800
      return {
×
801
        success: false,
802
        error: error instanceof Error ? error.message : 'Failed to generate diff',
×
803
      };
804
    }
805
  }
806

807
  /**
808
   * Recursively walk a git tree and collect all file paths with their blob OIDs
809
   */
810
  private async walkTree(
811
    dir: string,
812
    treeOid: string,
813
    prefix: string,
814
    result: Map<string, string>
815
  ): Promise<void> {
816
    const tree = await git.readTree({ fs, dir, oid: treeOid });
2✔
817

818
    for (const entry of tree.tree) {
2✔
819
      const fullPath = prefix ? `${prefix}/${entry.path}` : entry.path;
200!
820

821
      if (entry.type === 'blob') {
200!
822
        result.set(fullPath, entry.oid);
200✔
UNCOV
823
      } else if (entry.type === 'tree') {
×
824
        // Recursively walk subtrees
UNCOV
825
        await this.walkTree(dir, entry.oid, fullPath, result);
×
826
      }
827
    }
828
  }
829

830
  /**
831
   * Get diff for working directory changes (staged and unstaged)
832
   */
833
  async diffWorkingDir(dir: string): Promise<GitResult<DiffResult>> {
834
    try {
×
835
      const matrix = await git.statusMatrix({ fs, dir });
×
836
      const headOid = await git.resolveRef({ fs, dir, ref: 'HEAD' });
×
837

838
      const results = await Promise.all(
×
839
        matrix.map(async ([filepath, head, workdir, _stage]) => {
840
          // Skip unmodified files
UNCOV
841
          if (head === 1 && workdir === 1) return null;
×
842

843
          let type: FileDiff['type'];
UNCOV
844
          let oldContent = '';
×
UNCOV
845
          let newContent = '';
×
846

847
          if (head === 0 && workdir === 2) {
×
848
            // New file (untracked or added)
849
            type = 'add';
×
UNCOV
850
            try {
×
851
              newContent = await FileSystem.readAsStringAsync(`${dir}/${filepath}`);
×
852
            } catch {
UNCOV
853
              newContent = '';
×
854
            }
855
          } else if (head === 1 && workdir === 0) {
×
856
            // Deleted file
UNCOV
857
            type = 'delete';
×
UNCOV
858
            oldContent = (await readBlobContent(dir, headOid, filepath)) || '';
×
859
          } else {
860
            // Modified file
861
            type = 'modify';
×
862
            oldContent = (await readBlobContent(dir, headOid, filepath)) || '';
×
UNCOV
863
            try {
×
864
              newContent = await FileSystem.readAsStringAsync(`${dir}/${filepath}`);
×
865
            } catch {
UNCOV
866
              newContent = '';
×
867
            }
868
          }
869

UNCOV
870
          const { patch, additions, deletions } = createUnifiedPatch(
×
871
            filepath,
872
            oldContent,
873
            newContent
874
          );
875

UNCOV
876
          return {
×
877
            path: filepath,
878
            type,
879
            additions,
880
            deletions,
881
            patch,
882
          };
883
        })
884
      );
885

UNCOV
886
      const files: FileDiff[] = [];
×
UNCOV
887
      let totalAdditions = 0;
×
UNCOV
888
      let totalDeletions = 0;
×
889

UNCOV
890
      for (const result of results) {
×
UNCOV
891
        if (result) {
×
UNCOV
892
          files.push(result);
×
UNCOV
893
          totalAdditions += result.additions;
×
UNCOV
894
          totalDeletions += result.deletions;
×
895
        }
896
      }
897

UNCOV
898
      return {
×
899
        success: true,
900
        data: {
901
          files,
902
          stats: {
903
            filesChanged: files.length,
904
            additions: totalAdditions,
905
            deletions: totalDeletions,
906
          },
907
        },
908
      };
909
    } catch (error) {
UNCOV
910
      return {
×
911
        success: false,
912
        error: error instanceof Error ? error.message : 'Failed to generate working directory diff',
×
913
      };
914
    }
915
  }
916

917
  /**
918
   * List remotes
919
   */
920
  async listRemotes(dir: string): Promise<GitResult<RemoteInfo[]>> {
UNCOV
921
    try {
×
UNCOV
922
      const remotes = await git.listRemotes({ fs, dir });
×
923

UNCOV
924
      return {
×
925
        success: true,
926
        data: remotes.map((r) => ({
×
927
          name: r.remote,
928
          url: r.url,
929
        })),
930
      };
931
    } catch (error) {
UNCOV
932
      return {
×
933
        success: false,
934
        error: error instanceof Error ? error.message : 'Failed to list remotes',
×
935
      };
936
    }
937
  }
938

939
  /**
940
   * Add a remote
941
   */
942
  async addRemote(dir: string, name: string, url: string): Promise<GitResult<void>> {
UNCOV
943
    try {
×
UNCOV
944
      await git.addRemote({
×
945
        fs,
946
        dir,
947
        remote: name,
948
        url,
949
      });
950

UNCOV
951
      return { success: true };
×
952
    } catch (error) {
UNCOV
953
      return {
×
954
        success: false,
955
        error: error instanceof Error ? error.message : 'Failed to add remote',
×
956
      };
957
    }
958
  }
959

960
  /**
961
   * Delete a remote
962
   */
963
  async deleteRemote(dir: string, name: string): Promise<GitResult<void>> {
UNCOV
964
    try {
×
UNCOV
965
      await git.deleteRemote({
×
966
        fs,
967
        dir,
968
        remote: name,
969
      });
970

UNCOV
971
      return { success: true };
×
972
    } catch (error) {
UNCOV
973
      return {
×
974
        success: false,
975
        error: error instanceof Error ? error.message : 'Failed to delete remote',
×
976
      };
977
    }
978
  }
979

980
  /**
981
   * Initialize a new repository
982
   */
983
  async init(dir: string, defaultBranch = 'main'): Promise<GitResult<void>> {
1✔
984
    try {
1✔
985
      await fs.promises.mkdir(dir, { recursive: true });
1✔
986

987
      await git.init({
1✔
988
        fs,
989
        dir,
990
        defaultBranch,
991
      });
992

993
      return { success: true };
1✔
994
    } catch (error) {
UNCOV
995
      return {
×
996
        success: false,
997
        error: error instanceof Error ? error.message : 'Failed to initialize repository',
×
998
      };
999
    }
1000
  }
1001

1002
  /**
1003
   * Check if a directory is a Git repository
1004
   */
1005
  async isRepository(dir: string): Promise<boolean> {
1006
    try {
×
UNCOV
1007
      await git.resolveRef({
×
1008
        fs,
1009
        dir,
1010
        ref: 'HEAD',
1011
      });
UNCOV
1012
      return true;
×
1013
    } catch {
UNCOV
1014
      return false;
×
1015
    }
1016
  }
1017

1018
  /**
1019
   * Get the HEAD commit SHA
1020
   */
1021
  async getHead(dir: string): Promise<GitResult<string>> {
UNCOV
1022
    try {
×
UNCOV
1023
      const oid = await git.resolveRef({
×
1024
        fs,
1025
        dir,
1026
        ref: 'HEAD',
1027
      });
1028

UNCOV
1029
      return { success: true, data: oid };
×
1030
    } catch (error) {
UNCOV
1031
      return {
×
1032
        success: false,
1033
        error: error instanceof Error ? error.message : 'Failed to get HEAD',
×
1034
      };
1035
    }
1036
  }
1037

1038
  /**
1039
   * Clean up a repository (delete local clone)
1040
   */
1041
  async cleanup(dir: string): Promise<GitResult<void>> {
UNCOV
1042
    try {
×
UNCOV
1043
      await FileSystem.deleteAsync(dir, { idempotent: true });
×
UNCOV
1044
      return { success: true };
×
1045
    } catch (error) {
UNCOV
1046
      return {
×
1047
        success: false,
1048
        error: error instanceof Error ? error.message : 'Failed to cleanup repository',
×
1049
      };
1050
    }
1051
  }
1052
}
1053

1054
// Export singleton instance
1055
export const GitService = new GitServiceClass();
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc