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

agentic-dev-library / thumbcode / 21933729139

12 Feb 2026 04:36AM UTC coverage: 28.401% (+0.7%) from 27.702%
21933729139

Pull #116

github

web-flow
Merge b9d1b07d1 into c6c31bd07
Pull Request #116: refactor: decompose 9 monolith files into focused modules

406 of 2268 branches covered (17.9%)

Branch coverage included in aggregate %.

365 of 845 new or added lines in 22 files covered. (43.2%)

1 existing line in 1 file now uncovered.

1120 of 3105 relevant lines covered (36.07%)

7.7 hits per line

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

34.27
/packages/core/src/git/GitDiffService.ts
1
/**
2
 * Git Diff Service
3
 *
4
 * Handles diff and status operations: diff, diffWorkingDir, status.
5
 */
6

7
import * as Diff from 'diff';
8
import * as FileSystem from 'expo-file-system';
9
import git from 'isomorphic-git';
10

11
import { fs } from './git-fs';
12
import type {
13
  DiffResult,
14
  DiffStats,
15
  FileDiff,
16
  FileStatus,
17
  GitResult,
18
} from './types';
19

20
/**
21
 * Helper to read file content from a tree at specific commit
22
 */
23
async function readBlobContent(
24
  dir: string,
25
  oid: string,
26
  filepath: string
27
): Promise<string | null> {
28
  try {
200✔
29
    const { blob } = await git.readBlob({
200✔
30
      fs,
31
      dir,
32
      oid,
33
      filepath,
34
    });
35
    return new TextDecoder().decode(blob);
200✔
36
  } catch {
NEW
37
    return null;
×
38
  }
39
}
40

41
/**
42
 * Generate unified diff patch between two strings
43
 */
44
function createUnifiedPatch(
45
  filepath: string,
46
  oldContent: string,
47
  newContent: string
48
): { patch: string; additions: number; deletions: number } {
49
  const patch = Diff.createPatch(filepath, oldContent, newContent, 'old', 'new');
100✔
50

51
  // Count additions and deletions
52
  let additions = 0;
100✔
53
  let deletions = 0;
100✔
54
  const lines = patch.split('\n');
100✔
55
  for (const line of lines) {
100✔
56
    if (line.startsWith('+') && !line.startsWith('+++')) {
1,400✔
57
      additions++;
300✔
58
    } else if (line.startsWith('-') && !line.startsWith('---')) {
1,100✔
59
      deletions++;
200✔
60
    }
61
  }
62

63
  return { patch, additions, deletions };
100✔
64
}
65

66
class GitDiffServiceClass {
67
  /**
68
   * Get file status for the repository
69
   */
70
  async status(dir: string): Promise<GitResult<FileStatus[]>> {
NEW
71
    try {
×
NEW
72
      const matrix = await git.statusMatrix({ fs, dir });
×
73

NEW
74
      const statuses: FileStatus[] = matrix.map(([filepath, head, workdir, stage]) => {
×
75
        let status: FileStatus['status'];
NEW
76
        let staged = false;
×
77

78
        // Interpret status matrix
79
        // [HEAD, WORKDIR, STAGE]
NEW
80
        if (head === 0 && workdir === 2 && stage === 0) {
×
NEW
81
          status = 'untracked';
×
NEW
82
        } else if (head === 0 && workdir === 2 && stage === 2) {
×
NEW
83
          status = 'added';
×
NEW
84
          staged = true;
×
NEW
85
        } else if (head === 1 && workdir === 0 && stage === 0) {
×
NEW
86
          status = 'deleted';
×
NEW
87
        } else if (head === 1 && workdir === 0 && stage === 3) {
×
NEW
88
          status = 'deleted';
×
NEW
89
          staged = true;
×
NEW
90
        } else if (head === 1 && workdir === 2 && stage === 1) {
×
NEW
91
          status = 'modified';
×
NEW
92
        } else if (head === 1 && workdir === 2 && stage === 2) {
×
NEW
93
          status = 'modified';
×
NEW
94
          staged = true;
×
NEW
95
        } else if (head === 1 && workdir === 1 && stage === 1) {
×
NEW
96
          status = 'unmodified';
×
97
        } else {
NEW
98
          status = 'modified';
×
99
        }
100

NEW
101
        return {
×
102
          path: filepath,
103
          status,
104
          staged,
105
        };
106
      });
107

108
      // Filter out unmodified files for cleaner output
NEW
109
      return {
×
110
        success: true,
NEW
111
        data: statuses.filter((s) => s.status !== 'unmodified'),
×
112
      };
113
    } catch (error) {
NEW
114
      return {
×
115
        success: false,
116
        error: error instanceof Error ? error.message : 'Failed to get status',
×
117
      };
118
    }
119
  }
120

121
  /**
122
   * Get diff between two commits using tree walking
123
   * Performs full recursive tree comparison and generates unified diffs
124
   */
125
  async diff(dir: string, commitA: string, commitB: string): Promise<GitResult<DiffResult>> {
126
    try {
1✔
127
      // Resolve refs to commit OIDs
128
      const oidA = await git.resolveRef({ fs, dir, ref: commitA });
1✔
129
      const oidB = await git.resolveRef({ fs, dir, ref: commitB });
1✔
130

131
      // Read commit objects to get tree OIDs
132
      const commitObjA = await git.readCommit({ fs, dir, oid: oidA });
1✔
133
      const commitObjB = await git.readCommit({ fs, dir, oid: oidB });
1✔
134

135
      const treeA = commitObjA.commit.tree;
1✔
136
      const treeB = commitObjB.commit.tree;
1✔
137

138
      // Walk both trees simultaneously to find differences
139
      const files: FileDiff[] = [];
1✔
140
      const filesInA = new Map<string, string>(); // filepath -> blob oid
1✔
141
      const filesInB = new Map<string, string>(); // filepath -> blob oid
1✔
142

143
      // Walk tree A recursively
144
      await this.walkTree(dir, treeA, '', filesInA);
1✔
145
      // Walk tree B recursively
146
      await this.walkTree(dir, treeB, '', filesInB);
1✔
147

148
      // Find all unique files
149
      const allFiles = new Set([...filesInA.keys(), ...filesInB.keys()]);
1✔
150

151
      let totalAdditions = 0;
1✔
152
      let totalDeletions = 0;
1✔
153

154
      // Compare each file
155
      const allFilesArray = Array.from(allFiles);
1✔
156
      const BATCH_SIZE = 20;
1✔
157

158
      for (let i = 0; i < allFilesArray.length; i += BATCH_SIZE) {
1✔
159
        const batch = allFilesArray.slice(i, i + BATCH_SIZE);
5✔
160
        await Promise.all(
5✔
161
          batch.map(async (filepath) => {
162
            const blobA = filesInA.get(filepath);
100✔
163
            const blobB = filesInB.get(filepath);
100✔
164

165
            if (blobA === blobB) {
100!
166
              // File unchanged
NEW
167
              return;
×
168
            }
169

170
            let type: FileDiff['type'];
171
            let oldContent = '';
100✔
172
            let newContent = '';
100✔
173

174
            if (!blobA && blobB) {
100!
175
              // File added in B
NEW
176
              type = 'add';
×
NEW
177
              newContent = (await readBlobContent(dir, oidB, filepath)) || '';
×
178
            } else if (blobA && !blobB) {
100!
179
              // File deleted in B
NEW
180
              type = 'delete';
×
NEW
181
              oldContent = (await readBlobContent(dir, oidA, filepath)) || '';
×
182
            } else {
183
              // File modified
184
              type = 'modify';
100✔
185
              oldContent = (await readBlobContent(dir, oidA, filepath)) || '';
100!
186
              newContent = (await readBlobContent(dir, oidB, filepath)) || '';
100!
187
            }
188

189
            // Generate unified diff
190
            const { patch, additions, deletions } = createUnifiedPatch(
100✔
191
              filepath,
192
              oldContent,
193
              newContent
194
            );
195

196
            totalAdditions += additions;
100✔
197
            totalDeletions += deletions;
100✔
198

199
            files.push({
100✔
200
              path: filepath,
201
              type,
202
              additions,
203
              deletions,
204
              patch,
205
            });
206
          })
207
        );
208
      }
209

210
      const stats: DiffStats = {
1✔
211
        filesChanged: files.length,
212
        additions: totalAdditions,
213
        deletions: totalDeletions,
214
      };
215

216
      return {
1✔
217
        success: true,
218
        data: {
219
          files,
220
          stats,
221
        },
222
      };
223
    } catch (error) {
NEW
224
      return {
×
225
        success: false,
226
        error: error instanceof Error ? error.message : 'Failed to generate diff',
×
227
      };
228
    }
229
  }
230

231
  /**
232
   * Recursively walk a git tree and collect all file paths with their blob OIDs
233
   */
234
  private async walkTree(
235
    dir: string,
236
    treeOid: string,
237
    prefix: string,
238
    result: Map<string, string>
239
  ): Promise<void> {
240
    const tree = await git.readTree({ fs, dir, oid: treeOid });
2✔
241

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

245
      if (entry.type === 'blob') {
200!
246
        result.set(fullPath, entry.oid);
200✔
NEW
247
      } else if (entry.type === 'tree') {
×
248
        // Recursively walk subtrees
NEW
249
        await this.walkTree(dir, entry.oid, fullPath, result);
×
250
      }
251
    }
252
  }
253

254
  /**
255
   * Get diff for working directory changes (staged and unstaged)
256
   */
257
  async diffWorkingDir(dir: string): Promise<GitResult<DiffResult>> {
NEW
258
    try {
×
NEW
259
      const matrix = await git.statusMatrix({ fs, dir });
×
NEW
260
      const headOid = await git.resolveRef({ fs, dir, ref: 'HEAD' });
×
261

NEW
262
      const results = await Promise.all(
×
263
        matrix.map(async ([filepath, head, workdir, _stage]) => {
264
          // Skip unmodified files
NEW
265
          if (head === 1 && workdir === 1) return null;
×
266

267
          let type: FileDiff['type'];
NEW
268
          let oldContent = '';
×
NEW
269
          let newContent = '';
×
270

NEW
271
          if (head === 0 && workdir === 2) {
×
272
            // New file (untracked or added)
NEW
273
            type = 'add';
×
NEW
274
            try {
×
NEW
275
              newContent = await FileSystem.readAsStringAsync(`${dir}/${filepath}`);
×
276
            } catch {
NEW
277
              newContent = '';
×
278
            }
NEW
279
          } else if (head === 1 && workdir === 0) {
×
280
            // Deleted file
NEW
281
            type = 'delete';
×
NEW
282
            oldContent = (await readBlobContent(dir, headOid, filepath)) || '';
×
283
          } else {
284
            // Modified file
NEW
285
            type = 'modify';
×
NEW
286
            oldContent = (await readBlobContent(dir, headOid, filepath)) || '';
×
NEW
287
            try {
×
NEW
288
              newContent = await FileSystem.readAsStringAsync(`${dir}/${filepath}`);
×
289
            } catch {
NEW
290
              newContent = '';
×
291
            }
292
          }
293

NEW
294
          const { patch, additions, deletions } = createUnifiedPatch(
×
295
            filepath,
296
            oldContent,
297
            newContent
298
          );
299

NEW
300
          return {
×
301
            path: filepath,
302
            type,
303
            additions,
304
            deletions,
305
            patch,
306
          };
307
        })
308
      );
309

NEW
310
      const files: FileDiff[] = [];
×
NEW
311
      let totalAdditions = 0;
×
NEW
312
      let totalDeletions = 0;
×
313

NEW
314
      for (const result of results) {
×
NEW
315
        if (result) {
×
NEW
316
          files.push(result);
×
NEW
317
          totalAdditions += result.additions;
×
NEW
318
          totalDeletions += result.deletions;
×
319
        }
320
      }
321

NEW
322
      return {
×
323
        success: true,
324
        data: {
325
          files,
326
          stats: {
327
            filesChanged: files.length,
328
            additions: totalAdditions,
329
            deletions: totalDeletions,
330
          },
331
        },
332
      };
333
    } catch (error) {
NEW
334
      return {
×
335
        success: false,
336
        error: error instanceof Error ? error.message : 'Failed to generate working directory diff',
×
337
      };
338
    }
339
  }
340
}
341

342
export const GitDiffService = new GitDiffServiceClass();
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