Skip to content

Commit a32e072

Browse files
committed
feat(git): enhance file grouping with project boundaries
- Added ProjectBoundary interface for project detection - Implemented project detection logic in FileGrouper - Introduced new ProjectDetector class for scanning directories - Updated groupByPath and normalizeScope methods for boundaries - Added getRootDir method in GitService for directory access
1 parent 2299a7b commit a32e072

File tree

3 files changed

+148
-2
lines changed

3 files changed

+148
-2
lines changed

src/git/file-grouper.ts

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,21 @@
11
import type { FileChange } from '@/git/git.service.ts';
2+
import type { ProjectBoundary } from '@/git/project-detector.ts';
23

34
export interface FileGroup {
45
scope: string;
56
files: FileChange[];
67
}
78

89
export class FileGrouper {
10+
private boundaries: ProjectBoundary[];
11+
12+
constructor(boundaries: ProjectBoundary[] = []) {
13+
// Sort longest path first so the most specific project matches first
14+
this.boundaries = [...boundaries].sort(
15+
(a, b) => b.path.length - a.path.length,
16+
);
17+
}
18+
919
groupByPath(files: FileChange[]): FileGroup[] {
1020
if (files.length === 0) {
1121
return [];
@@ -33,10 +43,35 @@ export class FileGrouper {
3343
return 'root';
3444
}
3545

46+
const project = this.findProjectForFile(filePath);
47+
48+
if (project) {
49+
const relativePath = filePath.slice(project.path.length + 1);
50+
const relativeParts = relativePath.split('/');
51+
52+
if (relativeParts.length <= 1) {
53+
return project.path;
54+
}
55+
56+
const meaningfulParts = this.findMeaningfulPath(relativeParts);
57+
return `${project.path}/${meaningfulParts.join('/')}`;
58+
}
59+
60+
if (this.boundaries.length > 0) {
61+
// In a monorepo, root-level files that don't belong to any project
62+
return 'root';
63+
}
64+
3665
const meaningfulParts = this.findMeaningfulPath(parts);
3766
return meaningfulParts.join('/');
3867
}
3968

69+
private findProjectForFile(filePath: string): ProjectBoundary | undefined {
70+
return this.boundaries.find(
71+
b => filePath === b.path || filePath.startsWith(`${b.path}/`),
72+
);
73+
}
74+
4075
private findMeaningfulPath(parts: string[]): string[] {
4176
const commonDirs = ['src', 'lib', 'app', 'pages'];
4277
const startIdx = parts.findIndex(p => commonDirs.includes(p));
@@ -55,7 +90,6 @@ export class FileGrouper {
5590
}
5691

5792
optimizeGroups(groups: FileGroup[]): FileGroup[] {
58-
const optimized: FileGroup[] = [];
5993
const scopeMap = new Map<string, FileChange[]>();
6094

6195
for (const group of groups) {
@@ -65,6 +99,7 @@ export class FileGrouper {
6599
scopeMap.set(normalizedScope, existing);
66100
}
67101

102+
const optimized: FileGroup[] = [];
68103
for (const [scope, files] of scopeMap.entries()) {
69104
optimized.push({ scope, files });
70105
}
@@ -77,6 +112,19 @@ export class FileGrouper {
77112
}
78113

79114
private normalizeScope(scope: string): string {
115+
const project = this.findProjectForScope(scope);
116+
117+
if (project) {
118+
const subScope = scope.slice(project.path.length + 1);
119+
const parts = subScope.split('/');
120+
121+
if (parts.length > 2 && parts[0] === 'src') {
122+
return `${project.path}/${parts.slice(0, 2).join('/')}`;
123+
}
124+
125+
return scope;
126+
}
127+
80128
const parts = scope.split('/');
81129

82130
if (parts.length > 2 && parts[0] === 'src') {
@@ -85,5 +133,10 @@ export class FileGrouper {
85133

86134
return scope;
87135
}
88-
}
89136

137+
private findProjectForScope(scope: string): ProjectBoundary | undefined {
138+
return this.boundaries.find(
139+
b => scope === b.path || scope.startsWith(`${b.path}/`),
140+
);
141+
}
142+
}

src/git/git.service.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ export class GitService {
1717
this.git = simpleGit(this.rootDir);
1818
}
1919

20+
getRootDir(): string {
21+
return this.rootDir;
22+
}
23+
2024
async init(): Promise<void> {
2125
try {
2226
const root = await this.git.revparse(['--show-toplevel']);

src/git/project-detector.ts

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { readdirSync, statSync } from 'fs';
2+
import { join, relative } from 'path';
3+
4+
export interface ProjectBoundary {
5+
path: string;
6+
manifestFile: string;
7+
}
8+
9+
const MANIFEST_FILES = [
10+
'package.json',
11+
'Cargo.toml',
12+
'go.mod',
13+
'pyproject.toml',
14+
'pom.xml',
15+
'build.gradle',
16+
'composer.json',
17+
'pubspec.yaml',
18+
'mix.exs',
19+
'deno.json',
20+
];
21+
22+
const SKIP_DIRS = new Set([
23+
'node_modules',
24+
'.git',
25+
'dist',
26+
'build',
27+
'target',
28+
'vendor',
29+
'__pycache__',
30+
'.venv',
31+
'.next',
32+
'.nuxt',
33+
'.turbo',
34+
'.cache',
35+
'coverage',
36+
'out',
37+
]);
38+
39+
const MAX_DEPTH = 4;
40+
41+
export class ProjectDetector {
42+
static detect(rootDir: string): ProjectBoundary[] {
43+
const boundaries: ProjectBoundary[] = [];
44+
this.scan(rootDir, rootDir, 0, boundaries);
45+
return boundaries;
46+
}
47+
48+
private static scan(
49+
dir: string,
50+
rootDir: string,
51+
depth: number,
52+
boundaries: ProjectBoundary[],
53+
): void {
54+
if (depth > MAX_DEPTH) return;
55+
56+
let entries: string[];
57+
try {
58+
entries = readdirSync(dir);
59+
} catch {
60+
return;
61+
}
62+
63+
const isRoot = dir === rootDir;
64+
65+
if (!isRoot) {
66+
const manifest = entries.find(e => MANIFEST_FILES.includes(e));
67+
if (manifest) {
68+
boundaries.push({
69+
path: relative(rootDir, dir),
70+
manifestFile: manifest,
71+
});
72+
return; // Stop recursing into this project's internals
73+
}
74+
}
75+
76+
for (const entry of entries) {
77+
if (SKIP_DIRS.has(entry) || entry.startsWith('.')) continue;
78+
79+
const fullPath = join(dir, entry);
80+
try {
81+
if (statSync(fullPath).isDirectory()) {
82+
this.scan(fullPath, rootDir, depth + 1, boundaries);
83+
}
84+
} catch {
85+
continue;
86+
}
87+
}
88+
}
89+
}

0 commit comments

Comments
 (0)