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

apowers313 / aiforge / 21570337701

01 Feb 2026 09:11PM UTC coverage: 81.026% (-2.9%) from 83.954%
21570337701

push

github

apowers313
test: increase coverage to 80%+

2049 of 2382 branches covered (86.02%)

Branch coverage included in aggregate %.

1849 of 2529 new or added lines in 25 files covered. (73.11%)

681 existing lines in 21 files now uncovered.

9861 of 12317 relevant lines covered (80.06%)

26.33 hits per line

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

81.94
/src/server/services/git/GitService.ts
1
/**
2
 * GitService - Centralized git operations for a repository
3
 *
4
 * This service provides git operations for a specific directory.
5
 * Phase 2 implements: isGitRepository, hasCommits, listWorktrees
6
 */
7
import { simpleGit, type SimpleGit, type StatusResult, type RemoteWithRefs } from 'simple-git';
1✔
8
import { stat } from 'node:fs/promises';
1✔
9
import type { Worktree } from '@shared/types/index.js';
10

11
/**
12
 * Parse git worktree list --porcelain output into Worktree objects
13
 */
14
function parseWorktreeListOutput(output: string, mainWorktreePath: string): Worktree[] {
42✔
15
  const worktrees: Worktree[] = [];
42✔
16
  const lines = output.split('\n');
42✔
17

18
  let currentWorktree: Partial<Worktree> = {};
42✔
19

20
  for (const line of lines) {
42✔
21
    if (line.startsWith('worktree ')) {
319✔
22
      // Start of a new worktree entry
23
      if (currentWorktree.path) {
69✔
24
        // Save the previous worktree
25
        worktrees.push(currentWorktree as Worktree);
27✔
26
      }
27✔
27
      currentWorktree = {
69✔
28
        path: line.substring(9),
69✔
29
        branch: '',
69✔
30
        commit: '',
69✔
31
        isMain: false,
69✔
32
        isLocked: false,
69✔
33
      };
69✔
34
    } else if (line.startsWith('HEAD ')) {
319✔
35
      currentWorktree.commit = line.substring(5);
69✔
36
    } else if (line.startsWith('branch ')) {
250✔
37
      // Extract branch name from refs/heads/xxx format
38
      const fullRef = line.substring(7);
68✔
39
      currentWorktree.branch = fullRef.replace('refs/heads/', '');
68✔
40
    } else if (line === 'detached') {
181✔
41
      currentWorktree.branch = '(detached)';
1✔
42
    } else if (line === 'locked') {
113✔
43
      currentWorktree.isLocked = true;
1✔
44
    } else if (line === 'bare') {
112!
45
      // Skip bare worktrees
NEW
46
      currentWorktree = {};
×
NEW
47
    }
×
48
  }
319✔
49

50
  // Don't forget the last worktree
51
  if (currentWorktree.path) {
42✔
52
    worktrees.push(currentWorktree as Worktree);
42✔
53
  }
42✔
54

55
  // Mark the main worktree
56
  for (const wt of worktrees) {
42✔
57
    if (wt.path === mainWorktreePath) {
69✔
58
      wt.isMain = true;
42✔
59
    }
42✔
60
  }
69✔
61

62
  return worktrees;
42✔
63
}
42✔
64

65
/**
66
 * Check if a directory exists
67
 */
68
async function directoryExists(path: string): Promise<boolean> {
176✔
69
  try {
176✔
70
    const stats = await stat(path);
176✔
71
    return stats.isDirectory();
175✔
72
  } catch {
176✔
73
    return false;
1✔
74
  }
1✔
75
}
176✔
76

77
export class GitService {
1✔
78
  private git: SimpleGit | null = null;
177✔
79
  private readonly baseDir: string;
177✔
80

81
  constructor(baseDir: string) {
177✔
82
    this.baseDir = baseDir;
177✔
83
  }
177✔
84

85
  /**
86
   * Get or create the SimpleGit instance (lazy initialization)
87
   */
88
  private async getGit(): Promise<SimpleGit | null> {
177✔
89
    if (this.git) {
584✔
90
      return this.git;
408✔
91
    }
408✔
92

93
    // Check if directory exists before creating git instance
94
    if (!(await directoryExists(this.baseDir))) {
584✔
95
      return null;
1✔
96
    }
1✔
97

98
    this.git = simpleGit(this.baseDir);
175✔
99
    return this.git;
175✔
100
  }
584✔
101

102
  /**
103
   * Get the base directory this service operates on
104
   */
105
  getBaseDir(): string {
177✔
106
    return this.baseDir;
1✔
107
  }
1✔
108

109
  /**
110
   * Check if the directory is a git repository
111
   */
112
  async isGitRepository(): Promise<boolean> {
177✔
113
    try {
136✔
114
      const git = await this.getGit();
136✔
115
      if (!git) {
136✔
116
        return false;
1✔
117
      }
1✔
118
      return await git.checkIsRepo();
135✔
119
    } catch {
136!
NEW
120
      return false;
×
NEW
121
    }
×
122
  }
136✔
123

124
  /**
125
   * Check if the repository has any commits
126
   */
127
  async hasCommits(): Promise<boolean> {
177✔
128
    try {
118✔
129
      const isRepo = await this.isGitRepository();
118✔
130
      if (!isRepo) {
118✔
131
        return false;
7✔
132
      }
7✔
133

134
      const git = await this.getGit();
111✔
135
      if (!git) {
118!
NEW
136
        return false;
×
NEW
137
      }
✔
138

139
      // Try to get log - will fail if no commits
140
      const log = await git.log({ maxCount: 1 });
111✔
141
      return log.total > 0;
107✔
142
    } catch {
118✔
143
      return false;
4✔
144
    }
4✔
145
  }
118✔
146

147
  /**
148
   * List all worktrees in the repository
149
   * Returns empty array if not a git repo or has no commits
150
   */
151
  async listWorktrees(): Promise<Worktree[]> {
177✔
152
    try {
44✔
153
      // Must be a git repo with commits
154
      if (!(await this.hasCommits())) {
44✔
155
        return [];
2✔
156
      }
2✔
157

158
      const git = await this.getGit();
42✔
159
      if (!git) {
44!
NEW
160
        return [];
×
NEW
161
      }
✔
162

163
      // Get worktree list in porcelain format
164
      const output = await git.raw(['worktree', 'list', '--porcelain']);
42✔
165

166
      // Parse the output
167
      return parseWorktreeListOutput(output, this.baseDir);
42✔
168
    } catch {
44!
NEW
169
      return [];
×
NEW
170
    }
×
171
  }
44✔
172

173
  /**
174
   * Get the main branch name (main or master)
175
   * Returns 'main' by default if unable to determine
176
   */
177
  async getMainBranch(): Promise<string> {
177✔
178
    try {
44✔
179
      const git = await this.getGit();
44✔
180
      if (!git) {
44!
NEW
181
        return 'main';
×
NEW
182
      }
×
183

184
      // Try to get the default branch from git config
185
      try {
44✔
186
        const defaultBranch = await git.raw(['config', '--get', 'init.defaultBranch']);
44✔
187
        if (defaultBranch.trim()) {
44✔
188
          return defaultBranch.trim();
44✔
189
        }
44✔
190
      } catch {
44!
191
        // Config not set, fall through to branch check
NEW
192
      }
×
193

194
      // Check if common branch names exist
NEW
195
      const branches = await git.branchLocal();
×
NEW
196
      if (branches.all.includes('main')) {
×
NEW
197
        return 'main';
×
NEW
198
      }
×
NEW
199
      if (branches.all.includes('master')) {
×
NEW
200
        return 'master';
×
NEW
201
      }
×
202

203
      // Return the current branch if no main/master
NEW
204
      if (branches.current) {
×
NEW
205
        return branches.current;
×
NEW
206
      }
×
207

NEW
208
      return 'main';
×
NEW
209
    } catch {
×
NEW
210
      return 'main';
×
NEW
211
    }
×
212
  }
44✔
213

214
  /**
215
   * Check if a branch exists
216
   */
217
  async branchExists(branchName: string): Promise<boolean> {
177✔
218
    try {
91✔
219
      const git = await this.getGit();
91✔
220
      if (!git) {
91!
NEW
221
        return false;
×
NEW
222
      }
×
223

224
      const branches = await git.branchLocal();
91✔
225
      return branches.all.includes(branchName);
89✔
226
    } catch {
91✔
227
      return false;
2✔
228
    }
2✔
229
  }
91✔
230

231
  /**
232
   * Create a new worktree with a new branch
233
   * @param worktreePath - Path where the worktree will be created
234
   * @param branchName - Name of the new branch to create
235
   * @param baseBranch - Optional base branch to create from (defaults to detected main branch)
236
   */
237
  async createWorktree(worktreePath: string, branchName: string, baseBranch?: string): Promise<void> {
177✔
238
    const git = await this.getGit();
24✔
239
    if (!git) {
24!
NEW
240
      throw new Error('Not a git repository');
×
NEW
241
    }
×
242

243
    // Verify the repository has commits
244
    if (!(await this.hasCommits())) {
24✔
245
      throw new Error('Repository has no commits');
2✔
246
    }
2✔
247

248
    // Check if branch already exists
249
    if (await this.branchExists(branchName)) {
24✔
250
      throw new Error(`Branch '${branchName}' already exists`);
3✔
251
    }
3✔
252

253
    // Use detected main branch if baseBranch not specified
254
    const effectiveBaseBranch = baseBranch ?? (await this.getMainBranch());
24✔
255

256
    // Verify the base branch exists
257
    if (!(await this.branchExists(effectiveBaseBranch))) {
24✔
258
      throw new Error(`Base branch '${effectiveBaseBranch}' does not exist`);
3✔
259
    }
3✔
260

261
    // Build the git worktree add command
262
    const args = ['worktree', 'add', '-b', branchName, worktreePath, effectiveBaseBranch];
16✔
263

264
    await git.raw(args);
16✔
265
  }
24✔
266

267
  /**
268
   * Get the count of modified, staged, and untracked files
269
   * Returns 0 if not a git repository
270
   */
271
  async getModifiedFileCount(): Promise<number> {
177✔
272
    try {
46✔
273
      const git = await this.getGit();
46✔
274
      if (!git) {
46!
NEW
275
        return 0;
×
NEW
276
      }
×
277

278
      // Get status in porcelain format (machine-readable)
279
      const status = await git.status();
46✔
280

281
      // Count modified, staged, untracked, and deleted files
282
      const count =
45✔
283
        status.modified.length +
45✔
284
        status.staged.length +
45✔
285
        status.not_added.length +
45✔
286
        status.deleted.length +
45✔
287
        status.renamed.length;
45✔
288

289
      return count;
45✔
290
    } catch {
46✔
291
      return 0;
1✔
292
    }
1✔
293
  }
46✔
294

295
  /**
296
   * Get the ahead/behind commit counts relative to a target branch
297
   * Returns { ahead: 0, behind: 0 } if not a git repo or target branch doesn't exist
298
   */
299
  async getAheadBehind(targetBranch: string): Promise<{ ahead: number; behind: number }> {
177✔
300
    try {
46✔
301
      const git = await this.getGit();
46✔
302
      if (!git) {
46!
NEW
303
        return { ahead: 0, behind: 0 };
×
NEW
304
      }
×
305

306
      // Check if target branch exists
307
      if (!(await this.branchExists(targetBranch))) {
46✔
308
        return { ahead: 0, behind: 0 };
2✔
309
      }
2✔
310

311
      // Get the current branch
312
      const branches = await git.branchLocal();
44✔
313
      const currentBranch = branches.current;
44✔
314

315
      if (!currentBranch) {
46!
NEW
316
        return { ahead: 0, behind: 0 };
×
NEW
317
      }
✔
318

319
      // Use rev-list to count commits
320
      // --left-right gives us commits unique to each side
321
      // currentBranch...targetBranch shows symmetric difference
322
      const output = await git.raw([
44✔
323
        'rev-list',
44✔
324
        '--left-right',
44✔
325
        '--count',
44✔
326
        `${currentBranch}...${targetBranch}`,
44✔
327
      ]);
44✔
328

329
      // Output format: "ahead\tbehind"
330
      const parts = output.trim().split(/\s+/);
44✔
331
      const ahead = parseInt(parts[0] ?? '0', 10);
46!
332
      const behind = parseInt(parts[1] ?? '0', 10);
46!
333

334
      return {
46✔
335
        ahead: isNaN(ahead) ? 0 : ahead,
46!
336
        behind: isNaN(behind) ? 0 : behind,
46!
337
      };
46✔
338
    } catch {
46!
NEW
339
      return { ahead: 0, behind: 0 };
×
NEW
340
    }
×
341
  }
46✔
342

343
  /**
344
   * Remove a worktree
345
   * @param worktreePath - Path of the worktree to remove
346
   * @param force - Force removal even if worktree has uncommitted changes
347
   */
348
  async removeWorktree(worktreePath: string, force?: boolean): Promise<void> {
177✔
349
    // First check if this is a git repository
350
    if (!(await this.isGitRepository())) {
12✔
351
      throw new Error('Not a git repository');
2✔
352
    }
2✔
353

354
    const git = await this.getGit();
10✔
355
    if (!git) {
12!
NEW
356
      throw new Error('Not a git repository');
×
NEW
357
    }
✔
358

359
    // Check if this is the main worktree - cannot remove it
360
    const worktrees = await this.listWorktrees();
10✔
361
    const targetWorktree = worktrees.find((wt) => wt.path === worktreePath);
10✔
362

363
    if (targetWorktree?.isMain) {
12✔
364
      throw new Error('Cannot remove the main worktree');
3✔
365
    }
3✔
366

367
    // Build the git worktree remove command
368
    const args = ['worktree', 'remove'];
7✔
369
    if (force) {
12✔
370
      args.push('--force');
3✔
371
    }
3✔
372
    args.push(worktreePath);
7✔
373

374
    await git.raw(args);
7✔
375
  }
12✔
376

377
  /**
378
   * Get full git status for the repository
379
   * Phase 7: Used by FileTreeService for file status display
380
   * Returns null if not a git repository
381
   */
382
  async getStatus(): Promise<StatusResult | null> {
177✔
383
    try {
19✔
384
      const git = await this.getGit();
19✔
385
      if (!git) {
19!
NEW
386
        return null;
×
NEW
387
      }
×
388

389
      return await git.status();
19✔
390
    } catch {
19✔
391
      return null;
7✔
392
    }
7✔
393
  }
19✔
394

395
  /**
396
   * Get all remotes with their refs
397
   * Phase 7: Used by ProjectMetadataService for remote URL detection
398
   * Returns empty array if not a git repository
399
   */
400
  async getRemotes(): Promise<RemoteWithRefs[]> {
177✔
401
    try {
15✔
402
      const git = await this.getGit();
15✔
403
      if (!git) {
15!
NEW
404
        return [];
×
NEW
405
      }
×
406

407
      return await git.getRemotes(true);
15✔
408
    } catch {
15✔
409
      return [];
4✔
410
    }
4✔
411
  }
15✔
412
}
177✔
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