Skip to content

Commit 8c514b6

Browse files
authored
Merge pull request #986 from web3dev1337/fix/smart-projects-root-detection
fix: smart legacy ~/GitHub detection based on worktree layout
2 parents 070e44d + fc83923 commit 8c514b6

File tree

5 files changed

+182
-7
lines changed

5 files changed

+182
-7
lines changed
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Don't grandfather ~/GitHub as projects root when repos are flat clones (no worktree layout).
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
- [x] Add hasWorktreeLayout() and countWorktreeLayoutRepos() helpers
2+
- [x] Fix bootstrapProjectsRoot() to check worktree layout before grandfathering
3+
- [x] Add skip logging in server/index.js
4+
- [x] Add tests (hasWorktreeLayout, countWorktreeLayoutRepos, flat clone skip, worktree grandfather)
5+
- [x] All 550 tests pass

server/index.js

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,8 +79,16 @@ if (legacyCompatibilityState.shouldUseLegacyDir) {
7979
});
8080
}
8181
if (projectsRootBootstrap.usingLegacyProjectsRoot) {
82-
logger.info('Using legacy ~/GitHub as the projects root until ~/.agent-workspace/projects is populated', {
83-
projectsDir: projectsRootBootstrap.projectsDir
82+
logger.info('Using legacy ~/GitHub as the projects root (worktree layout detected)', {
83+
projectsDir: projectsRootBootstrap.projectsDir,
84+
total: projectsRootBootstrap.total,
85+
worktree: projectsRootBootstrap.worktree
86+
});
87+
} else if (projectsRootBootstrap.legacySkipReason) {
88+
logger.info('Skipping legacy ~/GitHub — repos do not use worktree layout', {
89+
reason: projectsRootBootstrap.legacySkipReason,
90+
total: projectsRootBootstrap.total,
91+
worktree: projectsRootBootstrap.worktree
8492
});
8593
}
8694

server/utils/pathUtils.js

Lines changed: 100 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -325,8 +325,90 @@ function hasVisibleEntries(dirPath) {
325325
}
326326

327327
/**
328-
* Preserve existing ~/GitHub installs unless the new projects root is already populated
329-
* or the user explicitly chose AGENT_WORKSPACE_PROJECTS_DIR.
328+
* Check whether a directory looks like a repo using the worktree layout
329+
* (has a master/ or main/ subdirectory containing a .git file or folder).
330+
*/
331+
function hasWorktreeLayout(dirPath) {
332+
const fs = require('fs');
333+
for (const name of ['master', 'main']) {
334+
const candidate = path.join(dirPath, name);
335+
try {
336+
const stat = fs.statSync(candidate);
337+
if (!stat.isDirectory()) continue;
338+
const gitPath = path.join(candidate, '.git');
339+
fs.statSync(gitPath);
340+
return true;
341+
} catch {
342+
// not found, try next
343+
}
344+
}
345+
return false;
346+
}
347+
348+
function isGitRepo(dirPath) {
349+
const fs = require('fs');
350+
try {
351+
fs.statSync(path.join(dirPath, '.git'));
352+
return true;
353+
} catch {
354+
return false;
355+
}
356+
}
357+
358+
const MAX_SCAN_DEPTH = 8;
359+
360+
/**
361+
* Recursively scan a directory tree to count repos and how many use
362+
* worktree layout. Stops recursing into directories that are repos
363+
* (so it never crawls node_modules, .git internals, etc.).
364+
* Depth-limited to MAX_SCAN_DEPTH as a safety net.
365+
* Returns { total, worktree } counts.
366+
*/
367+
function countWorktreeLayoutRepos(dirPath) {
368+
const fs = require('fs');
369+
let total = 0;
370+
let worktree = 0;
371+
372+
function scan(dir, depth) {
373+
if (depth > MAX_SCAN_DEPTH) return;
374+
375+
let entries;
376+
try {
377+
entries = fs.readdirSync(dir).filter((e) => !String(e || '').startsWith('.'));
378+
} catch {
379+
return;
380+
}
381+
382+
for (const entry of entries) {
383+
const entryPath = path.join(dir, entry);
384+
try {
385+
if (!fs.statSync(entryPath).isDirectory()) continue;
386+
} catch {
387+
continue;
388+
}
389+
390+
if (hasWorktreeLayout(entryPath)) {
391+
total++;
392+
worktree++;
393+
} else if (isGitRepo(entryPath)) {
394+
total++;
395+
} else {
396+
scan(entryPath, depth + 1);
397+
}
398+
}
399+
}
400+
401+
scan(dirPath, 0);
402+
return { total, worktree };
403+
}
404+
405+
/**
406+
* Preserve existing ~/GitHub installs only when a meaningful portion of its
407+
* repos use the worktree layout (master/ or main/ subdirectory with .git).
408+
* If the legacy dir is mostly flat clones, skip grandfathering so new repos
409+
* get created under ~/.agent-workspace/projects/ with proper layout.
410+
*
411+
* Configurable via AGENT_WORKSPACE_PROJECTS_DIR env var (explicit override).
330412
*/
331413
function bootstrapProjectsRoot() {
332414
const fs = require('fs');
@@ -346,11 +428,24 @@ function bootstrapProjectsRoot() {
346428
return { usingLegacyProjectsRoot: false, projectsDir };
347429
}
348430

431+
// Check if the legacy dir actually uses worktree layout
432+
const { total, worktree } = countWorktreeLayoutRepos(legacyDir);
433+
if (total > 0 && worktree === 0) {
434+
// All flat clones, no worktree layout at all — don't grandfather
435+
return { usingLegacyProjectsRoot: false, projectsDir, legacySkipReason: 'no-worktree-layout', total, worktree };
436+
}
437+
if (total >= 4 && worktree / total < 0.25) {
438+
// Very few repos use worktree layout — don't grandfather
439+
return { usingLegacyProjectsRoot: false, projectsDir, legacySkipReason: 'insufficient-worktree-layout', total, worktree };
440+
}
441+
349442
process.env.AGENT_WORKSPACE_PROJECTS_DIR = legacyDir;
350443
return {
351444
usingLegacyProjectsRoot: true,
352445
projectsDir: legacyDir,
353-
legacyDir
446+
legacyDir,
447+
total,
448+
worktree
354449
};
355450
}
356451

@@ -403,6 +498,8 @@ module.exports = {
403498
getLegacyProjectsRoot,
404499
migrateFromOrchestratorDir,
405500
bootstrapProjectsRoot,
501+
hasWorktreeLayout,
502+
countWorktreeLayoutRepos,
406503
resolveRepoConfigPath,
407504
REPO_CONFIG_NAME,
408505
LEGACY_REPO_CONFIG_NAME

tests/unit/pathUtils.test.js

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,9 +45,11 @@ describe('pathUtils', () => {
4545
expect(getTrailingPathLabel('/tmp/repo/work2', 1)).toBe('work2');
4646
});
4747

48-
test('bootstrapProjectsRoot falls back to legacy GitHub when the new projects root is empty', () => {
48+
test('bootstrapProjectsRoot falls back to legacy GitHub when repos use worktree layout', () => {
4949
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'path-utils-home-'));
50-
fs.mkdirSync(path.join(tmpHome, 'GitHub', 'games'), { recursive: true });
50+
// Create repos with worktree layout (master/ with .git)
51+
fs.mkdirSync(path.join(tmpHome, 'GitHub', 'repo-a', 'master', '.git'), { recursive: true });
52+
fs.mkdirSync(path.join(tmpHome, 'GitHub', 'repo-b', 'master', '.git'), { recursive: true });
5153

5254
const {
5355
bootstrapProjectsRoot,
@@ -63,6 +65,26 @@ describe('pathUtils', () => {
6365
expect(process.env.AGENT_WORKSPACE_PROJECTS_DIR).toBe(getLegacyProjectsRoot());
6466
});
6567

68+
test('bootstrapProjectsRoot skips legacy GitHub when repos are all flat clones', () => {
69+
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'path-utils-home-'));
70+
// Flat clones — no master/ subdirectory, just .git at root
71+
fs.mkdirSync(path.join(tmpHome, 'GitHub', 'repo-a', '.git'), { recursive: true });
72+
fs.mkdirSync(path.join(tmpHome, 'GitHub', 'repo-b', '.git'), { recursive: true });
73+
fs.mkdirSync(path.join(tmpHome, 'GitHub', 'repo-c', '.git'), { recursive: true });
74+
75+
const {
76+
bootstrapProjectsRoot,
77+
getProjectsRoot,
78+
getLegacyProjectsRoot
79+
} = loadPathUtils(tmpHome);
80+
81+
const result = bootstrapProjectsRoot();
82+
83+
expect(result.usingLegacyProjectsRoot).toBe(false);
84+
expect(result.legacySkipReason).toBe('no-worktree-layout');
85+
expect(getProjectsRoot()).not.toBe(getLegacyProjectsRoot());
86+
});
87+
6688
test('bootstrapProjectsRoot keeps the new projects root when it is already populated', () => {
6789
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'path-utils-home-'));
6890
fs.mkdirSync(path.join(tmpHome, 'GitHub', 'games'), { recursive: true });
@@ -82,6 +104,48 @@ describe('pathUtils', () => {
82104
expect(process.env.AGENT_WORKSPACE_PROJECTS_DIR).toBeUndefined();
83105
});
84106

107+
test('hasWorktreeLayout detects master/ with .git', () => {
108+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wt-layout-'));
109+
fs.mkdirSync(path.join(tmpDir, 'master', '.git'), { recursive: true });
110+
111+
const { hasWorktreeLayout } = loadPathUtils();
112+
expect(hasWorktreeLayout(tmpDir)).toBe(true);
113+
});
114+
115+
test('hasWorktreeLayout detects main/ with .git', () => {
116+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wt-layout-'));
117+
fs.mkdirSync(path.join(tmpDir, 'main', '.git'), { recursive: true });
118+
119+
const { hasWorktreeLayout } = loadPathUtils();
120+
expect(hasWorktreeLayout(tmpDir)).toBe(true);
121+
});
122+
123+
test('hasWorktreeLayout returns false for flat clones', () => {
124+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wt-layout-'));
125+
fs.mkdirSync(path.join(tmpDir, '.git'), { recursive: true });
126+
fs.mkdirSync(path.join(tmpDir, 'src'), { recursive: true });
127+
128+
const { hasWorktreeLayout } = loadPathUtils();
129+
expect(hasWorktreeLayout(tmpDir)).toBe(false);
130+
});
131+
132+
test('countWorktreeLayoutRepos counts repos at multiple nesting depths', () => {
133+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wt-count-'));
134+
// Depth 1: worktree layout
135+
fs.mkdirSync(path.join(tmpDir, 'repo-a', 'master', '.git'), { recursive: true });
136+
// Depth 2 (nested category): worktree layout
137+
fs.mkdirSync(path.join(tmpDir, 'games', 'zoo-game', 'master', '.git'), { recursive: true });
138+
// Depth 2: flat clone
139+
fs.mkdirSync(path.join(tmpDir, 'games', 'flat-game', '.git'), { recursive: true });
140+
// Depth 4 (deep nesting): worktree layout
141+
fs.mkdirSync(path.join(tmpDir, 'games', 'hytopia', 'games', 'hyfire', 'master', '.git'), { recursive: true });
142+
143+
const { countWorktreeLayoutRepos } = loadPathUtils();
144+
const result = countWorktreeLayoutRepos(tmpDir);
145+
expect(result.total).toBe(4);
146+
expect(result.worktree).toBe(3);
147+
});
148+
85149
test('getAgentWorkspaceDir falls back to legacy data when legacy has more workspaces', () => {
86150
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'path-utils-home-'));
87151
fs.mkdirSync(path.join(tmpHome, '.agent-workspace', 'workspaces'), { recursive: true });

0 commit comments

Comments
 (0)