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

agentic-dev-library / thumbcode / 21125632023

19 Jan 2026 04:45AM UTC coverage: 20.053% (-6.6%) from 26.701%
21125632023

Pull #72

github

web-flow
Merge 0acd95c8d into ff80413f8
Pull Request #72: feat(auth): implement GitHub Device Flow authentication

255 of 1892 branches covered (13.48%)

Branch coverage included in aggregate %.

0 of 415 new or added lines in 13 files covered. (0.0%)

192 existing lines in 6 files now uncovered.

646 of 2601 relevant lines covered (24.84%)

1.48 hits per line

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

0.0
/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
UNCOV
31
const http: HttpClient = async (url, options) => {
×
32
  // Delegate to the underlying implementation; types differ slightly but are runtime compatible
UNCOV
33
  const response = await gitHttpClient(url, options as any);
×
UNCOV
34
  return {
×
35
    ...response,
36
    // Localize the type assertion to the known body-type difference
37
    body: (response as any).body,
38
  };
39
};
40

41
import type {
42
  BranchInfo,
43
  BranchOptions,
44
  CheckoutOptions,
45
  CloneOptions,
46
  CommitInfo,
47
  CommitOptions,
48
  DiffResult,
49
  DiffStats,
50
  FetchOptions,
51
  FileDiff,
52
  FileStatus,
53
  GitCredentials,
54
  GitResult,
55
  PullOptions,
56
  PushOptions,
57
  RemoteInfo,
58
  StageOptions,
59
} from './types';
60

61
/**
62
 * File system adapter for isomorphic-git
63
 * Uses expo-file-system for React Native compatibility
64
 */
UNCOV
65
const fs = {
×
66
  promises: {
67
    readFile: async (filepath: string, options?: { encoding?: string }) => {
UNCOV
68
      const content = await FileSystem.readAsStringAsync(filepath, {
×
69
        encoding:
70
          options?.encoding === 'utf8'
×
71
            ? FileSystem.EncodingType.UTF8
72
            : FileSystem.EncodingType.Base64,
73
      });
74
      if (options?.encoding === 'utf8') {
×
75
        return content;
×
76
      }
77
      // Return as Buffer-like for binary files
UNCOV
78
      return Buffer.from(content, 'base64');
×
79
    },
80

81
    writeFile: async (
82
      filepath: string,
83
      data: string | Uint8Array,
84
      _options?: { mode?: number }
85
    ) => {
UNCOV
86
      const isString = typeof data === 'string';
×
UNCOV
87
      await FileSystem.writeAsStringAsync(
×
88
        filepath,
89
        isString ? data : Buffer.from(data).toString('base64'),
×
90
        {
91
          encoding: isString ? FileSystem.EncodingType.UTF8 : FileSystem.EncodingType.Base64,
×
92
        }
93
      );
94
    },
95

96
    unlink: async (filepath: string) => {
UNCOV
97
      await FileSystem.deleteAsync(filepath, { idempotent: true });
×
98
    },
99

100
    readdir: async (dirpath: string) => {
UNCOV
101
      const result = await FileSystem.readDirectoryAsync(dirpath);
×
UNCOV
102
      return result;
×
103
    },
104

105
    mkdir: async (dirpath: string, options?: { recursive?: boolean }) => {
106
      await FileSystem.makeDirectoryAsync(dirpath, {
×
107
        intermediates: options?.recursive ?? true,
×
108
      });
109
    },
110

111
    rmdir: async (dirpath: string) => {
UNCOV
112
      await FileSystem.deleteAsync(dirpath, { idempotent: true });
×
113
    },
114

115
    stat: async (filepath: string) => {
UNCOV
116
      const info = await FileSystem.getInfoAsync(filepath);
×
117
      return {
×
UNCOV
118
        isFile: () => !info.isDirectory,
×
UNCOV
119
        isDirectory: () => info.isDirectory,
×
UNCOV
120
        isSymbolicLink: () => false,
×
121
        size: info.exists && 'size' in info ? info.size : 0,
×
122
        mode: 0o644,
123
        mtimeMs: info.exists && 'modificationTime' in info ? info.modificationTime * 1000 : 0,
×
124
      };
125
    },
126

127
    lstat: async (filepath: string) => {
128
      // For React Native, lstat behaves same as stat
UNCOV
129
      return fs.promises.stat(filepath);
×
130
    },
131

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

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

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

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

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

180
  // Count additions and deletions
UNCOV
181
  let additions = 0;
×
UNCOV
182
  let deletions = 0;
×
UNCOV
183
  const lines = patch.split('\n');
×
UNCOV
184
  for (const line of lines) {
×
UNCOV
185
    if (line.startsWith('+') && !line.startsWith('+++')) {
×
UNCOV
186
      additions++;
×
UNCOV
187
    } else if (line.startsWith('-') && !line.startsWith('---')) {
×
UNCOV
188
      deletions++;
×
189
    }
190
  }
191

UNCOV
192
  return { patch, additions, deletions };
×
193
}
194

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

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

UNCOV
221
    try {
×
222
      // Ensure directory exists
UNCOV
223
      await fs.promises.mkdir(dir, { recursive: true });
×
224

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

395
  /**
396
   * Create a commit
397
   */
398
  async commit(options: CommitOptions): Promise<GitResult<string>> {
UNCOV
399
    const { dir, message, author, committer } = options;
×
400

UNCOV
401
    try {
×
UNCOV
402
      const sha = await git.commit({
×
403
        fs,
404
        dir,
405
        message,
406
        author: {
407
          name: author.name,
408
          email: author.email,
409
          timestamp: author.timestamp,
410
        },
411
        committer: committer
×
412
          ? {
413
              name: committer.name,
414
              email: committer.email,
415
              timestamp: committer.timestamp,
416
            }
417
          : undefined,
418
      });
419

UNCOV
420
      return { success: true, data: sha };
×
421
    } catch (error) {
422
      return {
×
423
        success: false,
424
        error: error instanceof Error ? error.message : 'Commit failed',
×
425
      };
426
    }
427
  }
428

429
  /**
430
   * Stage files
431
   */
432
  async stage(options: StageOptions): Promise<GitResult<void>> {
433
    const { dir, filepath } = options;
×
UNCOV
434
    const paths = Array.isArray(filepath) ? filepath : [filepath];
×
435

UNCOV
436
    try {
×
UNCOV
437
      for (const path of paths) {
×
UNCOV
438
        await git.add({
×
439
          fs,
440
          dir,
441
          filepath: path,
442
        });
443
      }
444

UNCOV
445
      return { success: true };
×
446
    } catch (error) {
447
      return {
×
448
        success: false,
449
        error: error instanceof Error ? error.message : 'Stage failed',
×
450
      };
451
    }
452
  }
453

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

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

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

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

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

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

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

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

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

UNCOV
529
    try {
×
UNCOV
530
      await git.checkout({
×
531
        fs,
532
        dir,
533
        ref,
534
        force,
535
      });
536

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

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

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

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

UNCOV
577
      const currentResult = await this.currentBranch(dir);
×
UNCOV
578
      const currentBranch = currentResult.success ? currentResult.data : undefined;
×
579

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

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

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

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

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

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

UNCOV
638
        return {
×
639
          path: filepath,
640
          status,
641
          staged,
642
        };
643
      });
644

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

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

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

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

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

704
      // Read commit objects to get tree OIDs
705
      const commitObjA = await git.readCommit({ fs, dir, oid: oidA });
×
UNCOV
706
      const commitObjB = await git.readCommit({ fs, dir, oid: oidB });
×
707

UNCOV
708
      const treeA = commitObjA.commit.tree;
×
UNCOV
709
      const treeB = commitObjB.commit.tree;
×
710

711
      // Walk both trees simultaneously to find differences
712
      const files: FileDiff[] = [];
×
713
      const filesInA = new Map<string, string>(); // filepath -> blob oid
×
UNCOV
714
      const filesInB = new Map<string, string>(); // filepath -> blob oid
×
715

716
      // Walk tree A recursively
717
      await this.walkTree(dir, treeA, '', filesInA);
×
718
      // Walk tree B recursively
UNCOV
719
      await this.walkTree(dir, treeB, '', filesInB);
×
720

721
      // Find all unique files
722
      const allFiles = new Set([...filesInA.keys(), ...filesInB.keys()]);
×
723

UNCOV
724
      let totalAdditions = 0;
×
UNCOV
725
      let totalDeletions = 0;
×
726

727
      // Compare each file
UNCOV
728
      for (const filepath of allFiles) {
×
729
        const blobA = filesInA.get(filepath);
×
UNCOV
730
        const blobB = filesInB.get(filepath);
×
731

732
        if (blobA === blobB) {
×
733
          // File unchanged
UNCOV
734
          continue;
×
735
        }
736

737
        let type: FileDiff['type'];
UNCOV
738
        let oldContent = '';
×
739
        let newContent = '';
×
740

741
        if (!blobA && blobB) {
×
742
          // File added in B
UNCOV
743
          type = 'add';
×
UNCOV
744
          newContent = (await readBlobContent(dir, oidB, filepath)) || '';
×
745
        } else if (blobA && !blobB) {
×
746
          // File deleted in B
UNCOV
747
          type = 'delete';
×
UNCOV
748
          oldContent = (await readBlobContent(dir, oidA, filepath)) || '';
×
749
        } else {
750
          // File modified
751
          type = 'modify';
×
752
          oldContent = (await readBlobContent(dir, oidA, filepath)) || '';
×
UNCOV
753
          newContent = (await readBlobContent(dir, oidB, filepath)) || '';
×
754
        }
755

756
        // Generate unified diff
UNCOV
757
        const { patch, additions, deletions } = createUnifiedPatch(
×
758
          filepath,
759
          oldContent,
760
          newContent
761
        );
762

763
        totalAdditions += additions;
×
UNCOV
764
        totalDeletions += deletions;
×
765

UNCOV
766
        files.push({
×
767
          path: filepath,
768
          type,
769
          additions,
770
          deletions,
771
          patch,
772
        });
773
      }
774

UNCOV
775
      const stats: DiffStats = {
×
776
        filesChanged: files.length,
777
        additions: totalAdditions,
778
        deletions: totalDeletions,
779
      };
780

UNCOV
781
      return {
×
782
        success: true,
783
        data: {
784
          files,
785
          stats,
786
        },
787
      };
788
    } catch (error) {
UNCOV
789
      return {
×
790
        success: false,
791
        error: error instanceof Error ? error.message : 'Failed to generate diff',
×
792
      };
793
    }
794
  }
795

796
  /**
797
   * Recursively walk a git tree and collect all file paths with their blob OIDs
798
   */
799
  private async walkTree(
800
    dir: string,
801
    treeOid: string,
802
    prefix: string,
803
    result: Map<string, string>
804
  ): Promise<void> {
UNCOV
805
    const tree = await git.readTree({ fs, dir, oid: treeOid });
×
806

UNCOV
807
    for (const entry of tree.tree) {
×
UNCOV
808
      const fullPath = prefix ? `${prefix}/${entry.path}` : entry.path;
×
809

UNCOV
810
      if (entry.type === 'blob') {
×
811
        result.set(fullPath, entry.oid);
×
812
      } else if (entry.type === 'tree') {
×
813
        // Recursively walk subtrees
UNCOV
814
        await this.walkTree(dir, entry.oid, fullPath, result);
×
815
      }
816
    }
817
  }
818

819
  /**
820
   * Get diff for working directory changes (staged and unstaged)
821
   */
822
  async diffWorkingDir(dir: string): Promise<GitResult<DiffResult>> {
UNCOV
823
    try {
×
824
      const matrix = await git.statusMatrix({ fs, dir });
×
825
      const headOid = await git.resolveRef({ fs, dir, ref: 'HEAD' });
×
826

827
      const files: FileDiff[] = [];
×
UNCOV
828
      let totalAdditions = 0;
×
829
      let totalDeletions = 0;
×
830

831
      for (const [filepath, head, workdir, _stage] of matrix) {
×
832
        // Skip unmodified files
833
        if (head === 1 && workdir === 1) continue;
×
834

835
        let type: FileDiff['type'];
UNCOV
836
        let oldContent = '';
×
837
        let newContent = '';
×
838

UNCOV
839
        if (head === 0 && workdir === 2) {
×
840
          // New file (untracked or added)
841
          type = 'add';
×
842
          try {
×
843
            newContent = await FileSystem.readAsStringAsync(`${dir}/${filepath}`);
×
844
          } catch {
UNCOV
845
            newContent = '';
×
846
          }
UNCOV
847
        } else if (head === 1 && workdir === 0) {
×
848
          // Deleted file
UNCOV
849
          type = 'delete';
×
850
          oldContent = (await readBlobContent(dir, headOid, filepath)) || '';
×
851
        } else {
852
          // Modified file
UNCOV
853
          type = 'modify';
×
UNCOV
854
          oldContent = (await readBlobContent(dir, headOid, filepath)) || '';
×
UNCOV
855
          try {
×
856
            newContent = await FileSystem.readAsStringAsync(`${dir}/${filepath}`);
×
857
          } catch {
UNCOV
858
            newContent = '';
×
859
          }
860
        }
861

UNCOV
862
        const { patch, additions, deletions } = createUnifiedPatch(
×
863
          filepath,
864
          oldContent,
865
          newContent
866
        );
867

868
        totalAdditions += additions;
×
UNCOV
869
        totalDeletions += deletions;
×
870

UNCOV
871
        files.push({
×
872
          path: filepath,
873
          type,
874
          additions,
875
          deletions,
876
          patch,
877
        });
878
      }
879

880
      return {
×
881
        success: true,
882
        data: {
883
          files,
884
          stats: {
885
            filesChanged: files.length,
886
            additions: totalAdditions,
887
            deletions: totalDeletions,
888
          },
889
        },
890
      };
891
    } catch (error) {
892
      return {
×
893
        success: false,
894
        error: error instanceof Error ? error.message : 'Failed to generate working directory diff',
×
895
      };
896
    }
897
  }
898

899
  /**
900
   * List remotes
901
   */
902
  async listRemotes(dir: string): Promise<GitResult<RemoteInfo[]>> {
UNCOV
903
    try {
×
UNCOV
904
      const remotes = await git.listRemotes({ fs, dir });
×
905

UNCOV
906
      return {
×
907
        success: true,
UNCOV
908
        data: remotes.map((r) => ({
×
909
          name: r.remote,
910
          url: r.url,
911
        })),
912
      };
913
    } catch (error) {
914
      return {
×
915
        success: false,
916
        error: error instanceof Error ? error.message : 'Failed to list remotes',
×
917
      };
918
    }
919
  }
920

921
  /**
922
   * Add a remote
923
   */
924
  async addRemote(dir: string, name: string, url: string): Promise<GitResult<void>> {
UNCOV
925
    try {
×
UNCOV
926
      await git.addRemote({
×
927
        fs,
928
        dir,
929
        remote: name,
930
        url,
931
      });
932

UNCOV
933
      return { success: true };
×
934
    } catch (error) {
935
      return {
×
936
        success: false,
937
        error: error instanceof Error ? error.message : 'Failed to add remote',
×
938
      };
939
    }
940
  }
941

942
  /**
943
   * Delete a remote
944
   */
945
  async deleteRemote(dir: string, name: string): Promise<GitResult<void>> {
UNCOV
946
    try {
×
UNCOV
947
      await git.deleteRemote({
×
948
        fs,
949
        dir,
950
        remote: name,
951
      });
952

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

962
  /**
963
   * Initialize a new repository
964
   */
965
  async init(dir: string, defaultBranch = 'main'): Promise<GitResult<void>> {
×
UNCOV
966
    try {
×
UNCOV
967
      await fs.promises.mkdir(dir, { recursive: true });
×
968

UNCOV
969
      await git.init({
×
970
        fs,
971
        dir,
972
        defaultBranch,
973
      });
974

UNCOV
975
      return { success: true };
×
976
    } catch (error) {
977
      return {
×
978
        success: false,
979
        error: error instanceof Error ? error.message : 'Failed to initialize repository',
×
980
      };
981
    }
982
  }
983

984
  /**
985
   * Check if a directory is a Git repository
986
   */
987
  async isRepository(dir: string): Promise<boolean> {
UNCOV
988
    try {
×
UNCOV
989
      await git.resolveRef({
×
990
        fs,
991
        dir,
992
        ref: 'HEAD',
993
      });
UNCOV
994
      return true;
×
995
    } catch {
UNCOV
996
      return false;
×
997
    }
998
  }
999

1000
  /**
1001
   * Get the HEAD commit SHA
1002
   */
1003
  async getHead(dir: string): Promise<GitResult<string>> {
UNCOV
1004
    try {
×
UNCOV
1005
      const oid = await git.resolveRef({
×
1006
        fs,
1007
        dir,
1008
        ref: 'HEAD',
1009
      });
1010

UNCOV
1011
      return { success: true, data: oid };
×
1012
    } catch (error) {
1013
      return {
×
1014
        success: false,
1015
        error: error instanceof Error ? error.message : 'Failed to get HEAD',
×
1016
      };
1017
    }
1018
  }
1019

1020
  /**
1021
   * Clean up a repository (delete local clone)
1022
   */
1023
  async cleanup(dir: string): Promise<GitResult<void>> {
UNCOV
1024
    try {
×
1025
      await FileSystem.deleteAsync(dir, { idempotent: true });
×
UNCOV
1026
      return { success: true };
×
1027
    } catch (error) {
UNCOV
1028
      return {
×
1029
        success: false,
1030
        error: error instanceof Error ? error.message : 'Failed to cleanup repository',
×
1031
      };
1032
    }
1033
  }
1034
}
1035

1036
// Export singleton instance
UNCOV
1037
export const GitService = new GitServiceClass();
×
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