@nemus-cli/nemus 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +86 -0
- package/LICENSE +21 -0
- package/README.md +275 -0
- package/assets/README.md +53 -0
- package/assets/banner.png +0 -0
- package/assets/banner.svg +66 -0
- package/assets/favicon-32.png +0 -0
- package/assets/favicon-64.png +0 -0
- package/assets/icon-256.png +0 -0
- package/assets/icon.svg +23 -0
- package/assets/logo-256.png +0 -0
- package/assets/logo-512.png +0 -0
- package/assets/logo-wordmark.svg +35 -0
- package/assets/logo.svg +32 -0
- package/bin/workspace.js +130 -0
- package/dist/cli/ai-prompt.js +421 -0
- package/dist/cli/dashboard/AgentList.js +61 -0
- package/dist/cli/dashboard/DashboardSidebar.js +280 -0
- package/dist/cli/dashboard/HelpFooter.js +73 -0
- package/dist/cli/dashboard/StatusBadge.js +50 -0
- package/dist/cli/dashboard/index.js +11 -0
- package/dist/cli/dashboard/types.js +2 -0
- package/dist/cli/tui.js +48 -0
- package/dist/commands/ai-prompt.js +22 -0
- package/dist/commands/analyze-deps.js +138 -0
- package/dist/commands/archive.js +113 -0
- package/dist/commands/branch/create.js +119 -0
- package/dist/commands/branch/index.js +104 -0
- package/dist/commands/branch/merge.js +90 -0
- package/dist/commands/branch/rebase.js +84 -0
- package/dist/commands/branch/switch.js +165 -0
- package/dist/commands/cache/index.js +80 -0
- package/dist/commands/cache/manager.js +198 -0
- package/dist/commands/cleanup.js +153 -0
- package/dist/commands/configure-claude.js +55 -0
- package/dist/commands/configure.js +231 -0
- package/dist/commands/create.js +204 -0
- package/dist/commands/dashboard/hook-handler.js +162 -0
- package/dist/commands/dashboard/index.js +258 -0
- package/dist/commands/dashboard/launcher.js +40 -0
- package/dist/commands/dashboard/session-picker.js +106 -0
- package/dist/commands/dashboard/sidebar.js +39 -0
- package/dist/commands/dashboard/workspace-picker.js +95 -0
- package/dist/commands/delete.js +194 -0
- package/dist/commands/deprecated-aliases.js +44 -0
- package/dist/commands/diff.js +129 -0
- package/dist/commands/doctor.js +141 -0
- package/dist/commands/generate-docs.js +77 -0
- package/dist/commands/ghq-status.js +72 -0
- package/dist/commands/go.js +154 -0
- package/dist/commands/history.js +124 -0
- package/dist/commands/list.js +191 -0
- package/dist/commands/mcp/index.js +69 -0
- package/dist/commands/migrate.js +296 -0
- package/dist/commands/remove-repo.js +217 -0
- package/dist/commands/report-bug.js +112 -0
- package/dist/commands/run.js +147 -0
- package/dist/commands/save-context.js +171 -0
- package/dist/commands/sessions.js +129 -0
- package/dist/commands/status.js +138 -0
- package/dist/commands/suite/create.js +212 -0
- package/dist/commands/suite/delete.js +64 -0
- package/dist/commands/suite/export.js +126 -0
- package/dist/commands/suite/import.js +128 -0
- package/dist/commands/suite/index.js +94 -0
- package/dist/commands/suite/list.js +49 -0
- package/dist/commands/suite/use.js +227 -0
- package/dist/commands/sync.js +117 -0
- package/dist/commands/update.js +203 -0
- package/dist/mcp/install.js +550 -0
- package/dist/mcp/server.js +398 -0
- package/dist/mcp/tools.js +922 -0
- package/dist/pi-extensions/permission-sync.ts.template +41 -0
- package/dist/pi-extensions/permissions-gate.ts.template +69 -0
- package/dist/pi-extensions/pr-fix-trigger.ts.template +124 -0
- package/dist/pi-extensions/review-reminder.ts.template +58 -0
- package/dist/pi-extensions/workspace-input-status.ts.template +919 -0
- package/dist/program.js +136 -0
- package/dist/scripts/workspace-table.py +394 -0
- package/dist/types/dashboard.js +11 -0
- package/dist/types/index.js +2 -0
- package/dist/utils/agent-config.js +263 -0
- package/dist/utils/agent-state.js +178 -0
- package/dist/utils/banner.js +24 -0
- package/dist/utils/branch-operations.js +232 -0
- package/dist/utils/bug-report.js +355 -0
- package/dist/utils/cache.js +168 -0
- package/dist/utils/claude-integration.js +617 -0
- package/dist/utils/claude-sessions.js +260 -0
- package/dist/utils/cleanup-operations.js +120 -0
- package/dist/utils/colors.js +26 -0
- package/dist/utils/command-helpers.js +44 -0
- package/dist/utils/config.js +150 -0
- package/dist/utils/context-file.js +33 -0
- package/dist/utils/dashboard-hooks.js +172 -0
- package/dist/utils/dependency-analyzer.js +234 -0
- package/dist/utils/diff-operations.js +80 -0
- package/dist/utils/doc-generator.js +184 -0
- package/dist/utils/ghq-integration.js +332 -0
- package/dist/utils/git-operations.js +237 -0
- package/dist/utils/git-status.js +187 -0
- package/dist/utils/github.js +68 -0
- package/dist/utils/health-checks.js +345 -0
- package/dist/utils/history.js +150 -0
- package/dist/utils/hooks.js +100 -0
- package/dist/utils/logger.js +40 -0
- package/dist/utils/permission-sync.js +681 -0
- package/dist/utils/pi-extensions.js +224 -0
- package/dist/utils/progress.js +92 -0
- package/dist/utils/prompts.js +279 -0
- package/dist/utils/repo-resolver.js +260 -0
- package/dist/utils/retry.js +65 -0
- package/dist/utils/run-operations.js +30 -0
- package/dist/utils/suite.js +159 -0
- package/dist/utils/sync-operations.js +107 -0
- package/dist/utils/tmux-dashboard.js +184 -0
- package/dist/utils/validation.js +153 -0
- package/dist/utils/version-check.js +121 -0
- package/dist/utils/workspace-meta.js +185 -0
- package/install-shell-integration.sh +532 -0
- package/install.sh +401 -0
- package/package.json +90 -0
- package/remove-shell-block.awk +44 -0
- package/scripts/postinstall.js +85 -0
- package/skills/analyze-deps.md +23 -0
- package/skills/archive-workspace.md +26 -0
- package/skills/branch-create.md +18 -0
- package/skills/create-workspace.md +39 -0
- package/skills/delete-workspace.md +37 -0
- package/skills/list-org-repos.md +26 -0
- package/skills/list-suites.md +24 -0
- package/skills/list-workspaces.md +27 -0
- package/skills/refresh-workspace-docs.md +89 -0
- package/skills/remove-repo.md +25 -0
- package/skills/run-command.md +23 -0
- package/skills/save-context.md +68 -0
- package/skills/search-repos.md +31 -0
- package/skills/snapshot-list.md +18 -0
- package/skills/snapshot-save.md +12 -0
- package/skills/switch-branch.md +18 -0
- package/skills/update-cache.md +21 -0
- package/skills/update-workspace.md +66 -0
- package/skills/workspace-cleanup.md +18 -0
- package/skills/workspace-diff.md +23 -0
- package/skills/workspace-doctor.md +23 -0
- package/skills/workspace-info.md +45 -0
- package/skills/workspace-manager/SKILL.md +201 -0
- package/skills/workspace-manager/references/ai-prompt.md +44 -0
- package/skills/workspace-manager/references/analyze-deps.md +45 -0
- package/skills/workspace-manager/references/archive-workspace.md +38 -0
- package/skills/workspace-manager/references/branch-create.md +31 -0
- package/skills/workspace-manager/references/branch-merge.md +22 -0
- package/skills/workspace-manager/references/branch-rebase.md +22 -0
- package/skills/workspace-manager/references/cache-clear.md +18 -0
- package/skills/workspace-manager/references/cache-info.md +22 -0
- package/skills/workspace-manager/references/configure-claude.md +31 -0
- package/skills/workspace-manager/references/configure.md +35 -0
- package/skills/workspace-manager/references/create-workspace.md +45 -0
- package/skills/workspace-manager/references/dashboard.md +61 -0
- package/skills/workspace-manager/references/delete-workspace.md +33 -0
- package/skills/workspace-manager/references/generate-docs.md +36 -0
- package/skills/workspace-manager/references/ghq-status.md +28 -0
- package/skills/workspace-manager/references/go.md +33 -0
- package/skills/workspace-manager/references/history.md +41 -0
- package/skills/workspace-manager/references/list-org-repos.md +26 -0
- package/skills/workspace-manager/references/list-suites.md +21 -0
- package/skills/workspace-manager/references/list-workspaces.md +27 -0
- package/skills/workspace-manager/references/mcp.md +49 -0
- package/skills/workspace-manager/references/remove-repo.md +26 -0
- package/skills/workspace-manager/references/run-command.md +44 -0
- package/skills/workspace-manager/references/search-repos.md +24 -0
- package/skills/workspace-manager/references/sessions.md +27 -0
- package/skills/workspace-manager/references/suite-create.md +22 -0
- package/skills/workspace-manager/references/suite-delete.md +21 -0
- package/skills/workspace-manager/references/suite-export.md +19 -0
- package/skills/workspace-manager/references/suite-import.md +19 -0
- package/skills/workspace-manager/references/suite-use.md +27 -0
- package/skills/workspace-manager/references/switch-branch.md +25 -0
- package/skills/workspace-manager/references/update-cache.md +22 -0
- package/skills/workspace-manager/references/update-workspace.md +50 -0
- package/skills/workspace-manager/references/workspace-cleanup.md +33 -0
- package/skills/workspace-manager/references/workspace-diff.md +27 -0
- package/skills/workspace-manager/references/workspace-doctor.md +27 -0
- package/skills/workspace-manager/references/workspace-info.md +33 -0
- package/skills/workspace-manager/references/workspace-status.md +43 -0
- package/skills/workspace-manager/references/workspace-sync.md +26 -0
- package/skills/workspace-status.md +26 -0
- package/skills/workspace-sync.md +23 -0
- package/src/cli/ai-prompt.test.ts +667 -0
- package/src/cli/ai-prompt.ts +421 -0
- package/src/cli/dashboard/AgentList.tsx +44 -0
- package/src/cli/dashboard/DashboardSidebar.tsx +291 -0
- package/src/cli/dashboard/HelpFooter.tsx +22 -0
- package/src/cli/dashboard/StatusBadge.tsx +23 -0
- package/src/cli/dashboard/index.ts +5 -0
- package/src/cli/dashboard/types.ts +16 -0
- package/src/cli/tui.tsx +51 -0
- package/src/cli/workspace-cli.test.ts +30 -0
- package/src/commands/ai-prompt.ts +21 -0
- package/src/commands/analyze-deps.ts +119 -0
- package/src/commands/archive.ts +114 -0
- package/src/commands/branch/create.ts +94 -0
- package/src/commands/branch/index.ts +74 -0
- package/src/commands/branch/merge.ts +61 -0
- package/src/commands/branch/rebase.ts +54 -0
- package/src/commands/branch/switch.ts +150 -0
- package/src/commands/cache/index.ts +51 -0
- package/src/commands/cache/manager.ts +168 -0
- package/src/commands/cleanup.ts +132 -0
- package/src/commands/configure-claude.ts +55 -0
- package/src/commands/configure.ts +204 -0
- package/src/commands/create.ts +196 -0
- package/src/commands/dashboard/hook-handler.ts +138 -0
- package/src/commands/dashboard/index.ts +248 -0
- package/src/commands/dashboard/launcher.ts +42 -0
- package/src/commands/dashboard/session-picker.ts +73 -0
- package/src/commands/dashboard/sidebar.js +39 -0
- package/src/commands/dashboard/workspace-picker.ts +62 -0
- package/src/commands/delete.test.ts +250 -0
- package/src/commands/delete.ts +171 -0
- package/src/commands/deprecated-aliases.ts +55 -0
- package/src/commands/diff.ts +106 -0
- package/src/commands/doctor.ts +110 -0
- package/src/commands/generate-docs.ts +45 -0
- package/src/commands/ghq-status.ts +70 -0
- package/src/commands/go.ts +125 -0
- package/src/commands/history.ts +136 -0
- package/src/commands/list.test.ts +219 -0
- package/src/commands/list.ts +176 -0
- package/src/commands/mcp/index.ts +39 -0
- package/src/commands/migrate.ts +277 -0
- package/src/commands/remove-repo.ts +194 -0
- package/src/commands/report-bug.ts +88 -0
- package/src/commands/run.ts +125 -0
- package/src/commands/save-context.test.ts +113 -0
- package/src/commands/save-context.ts +141 -0
- package/src/commands/sessions.ts +99 -0
- package/src/commands/status.ts +111 -0
- package/src/commands/suite/create.ts +240 -0
- package/src/commands/suite/delete.ts +65 -0
- package/src/commands/suite/export.ts +97 -0
- package/src/commands/suite/import.ts +100 -0
- package/src/commands/suite/index.ts +66 -0
- package/src/commands/suite/list.ts +54 -0
- package/src/commands/suite/use.ts +232 -0
- package/src/commands/sync.ts +97 -0
- package/src/commands/update.ts +197 -0
- package/src/mcp/install.ts +529 -0
- package/src/mcp/server.ts +579 -0
- package/src/mcp/tools.test.ts +545 -0
- package/src/mcp/tools.ts +1019 -0
- package/src/mcp/update-workspace.test.ts +220 -0
- package/src/pi-extensions/permission-sync.ts.template +41 -0
- package/src/pi-extensions/permissions-gate.ts.template +69 -0
- package/src/pi-extensions/pr-fix-trigger.ts.template +124 -0
- package/src/pi-extensions/review-reminder.ts.template +58 -0
- package/src/pi-extensions/workspace-input-status.ts.template +919 -0
- package/src/program.ts +112 -0
- package/src/scripts/workspace-table.py +394 -0
- package/src/types/dashboard.ts +29 -0
- package/src/types/index.ts +109 -0
- package/src/types/ink.d.ts +171 -0
- package/src/utils/agent-config.test.ts +303 -0
- package/src/utils/agent-config.ts +264 -0
- package/src/utils/agent-state.test.ts +166 -0
- package/src/utils/agent-state.ts +135 -0
- package/src/utils/banner.ts +24 -0
- package/src/utils/branch-operations.ts +237 -0
- package/src/utils/bug-report.test.ts +174 -0
- package/src/utils/bug-report.ts +355 -0
- package/src/utils/cache.ts +150 -0
- package/src/utils/claude-integration.test.ts +381 -0
- package/src/utils/claude-integration.ts +649 -0
- package/src/utils/claude-sessions.test.ts +262 -0
- package/src/utils/claude-sessions.ts +251 -0
- package/src/utils/cleanup-operations.ts +94 -0
- package/src/utils/colors.ts +25 -0
- package/src/utils/command-helpers.ts +46 -0
- package/src/utils/config.test.ts +203 -0
- package/src/utils/config.ts +133 -0
- package/src/utils/context-file.ts +36 -0
- package/src/utils/dashboard-hooks.test.ts +168 -0
- package/src/utils/dashboard-hooks.ts +163 -0
- package/src/utils/dependency-analyzer.ts +243 -0
- package/src/utils/diff-operations.ts +59 -0
- package/src/utils/doc-generator.ts +187 -0
- package/src/utils/ghq-integration.test.ts +195 -0
- package/src/utils/ghq-integration.ts +317 -0
- package/src/utils/git-operations.test.ts +265 -0
- package/src/utils/git-operations.ts +233 -0
- package/src/utils/git-status.ts +171 -0
- package/src/utils/github.ts +74 -0
- package/src/utils/health-checks.ts +359 -0
- package/src/utils/history.ts +140 -0
- package/src/utils/hooks.ts +92 -0
- package/src/utils/logger.ts +37 -0
- package/src/utils/permission-sync.test.ts +593 -0
- package/src/utils/permission-sync.ts +720 -0
- package/src/utils/pi-extensions.test.ts +182 -0
- package/src/utils/pi-extensions.ts +226 -0
- package/src/utils/progress.ts +73 -0
- package/src/utils/prompts.test.ts +169 -0
- package/src/utils/prompts.ts +296 -0
- package/src/utils/repo-resolver.test.ts +313 -0
- package/src/utils/repo-resolver.ts +294 -0
- package/src/utils/retry.test.ts +124 -0
- package/src/utils/retry.ts +85 -0
- package/src/utils/run-operations.ts +38 -0
- package/src/utils/suite.test.ts +256 -0
- package/src/utils/suite.ts +144 -0
- package/src/utils/sync-operations.ts +87 -0
- package/src/utils/tmux-dashboard.ts +193 -0
- package/src/utils/validation.test.ts +139 -0
- package/src/utils/validation.ts +128 -0
- package/src/utils/version-check.test.ts +108 -0
- package/src/utils/version-check.ts +96 -0
- package/src/utils/workspace-meta.ts +173 -0
- package/sync-permissions.sh +137 -0
- package/tsconfig.json +20 -0
- package/uninstall.sh +162 -0
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
|
|
3
|
+
const mockExecAsync = vi.fn();
|
|
4
|
+
vi.mock('child_process', () => ({
|
|
5
|
+
exec: (...args: unknown[]) => {
|
|
6
|
+
const callback = args[args.length - 1] as (err: Error | null, result: { stdout: string; stderr: string }) => void;
|
|
7
|
+
const command = args[0] as string;
|
|
8
|
+
const opts = args.length === 3 ? args[1] : {};
|
|
9
|
+
mockExecAsync(command, opts)
|
|
10
|
+
.then((result: { stdout: string; stderr: string }) => callback(null, result))
|
|
11
|
+
.catch((err: Error) => callback(err, { stdout: '', stderr: '' }));
|
|
12
|
+
},
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
vi.mock('./logger', () => ({
|
|
16
|
+
logSuccess: vi.fn(),
|
|
17
|
+
logError: vi.fn(),
|
|
18
|
+
logInfo: vi.fn(),
|
|
19
|
+
logWarning: vi.fn(),
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
vi.mock('./colors', () => ({
|
|
23
|
+
colorize: (text: string) => text,
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
import { cloneWithGhq, describeCloneError } from './ghq-integration';
|
|
27
|
+
|
|
28
|
+
describe('cloneWithGhq', () => {
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
vi.clearAllMocks();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('uses git clone --local from ghq cache when repo exists', async () => {
|
|
34
|
+
mockExecAsync.mockImplementation((cmd: string) => {
|
|
35
|
+
if (cmd === 'which ghq') return Promise.resolve({ stdout: '/usr/bin/ghq\n', stderr: '' });
|
|
36
|
+
if (cmd === 'ghq root') return Promise.resolve({ stdout: '/home/user/ghq\n', stderr: '' });
|
|
37
|
+
if (cmd.startsWith('test -d')) return Promise.resolve({ stdout: '', stderr: '' });
|
|
38
|
+
if (cmd.startsWith('git clone --local')) return Promise.resolve({ stdout: '', stderr: '' });
|
|
39
|
+
if (cmd.startsWith('git remote set-url')) return Promise.resolve({ stdout: '', stderr: '' });
|
|
40
|
+
return Promise.resolve({ stdout: '', stderr: '' });
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const result = await cloneWithGhq('git@github.com:acme/platform-app.git', '/workspace/platform-app');
|
|
44
|
+
|
|
45
|
+
expect(result.success).toBe(true);
|
|
46
|
+
expect(result.usedGhq).toBe(true);
|
|
47
|
+
|
|
48
|
+
// Should use git clone --local, not cp -R
|
|
49
|
+
const calls = mockExecAsync.mock.calls.map(([cmd]: [string]) => cmd);
|
|
50
|
+
expect(calls.some((cmd: string) => cmd.includes('git clone --local'))).toBe(true);
|
|
51
|
+
expect(calls.some((cmd: string) => cmd.includes('cp -R'))).toBe(false);
|
|
52
|
+
|
|
53
|
+
// Should set remote URL back to original
|
|
54
|
+
expect(calls.some((cmd: string) => cmd.includes('git remote set-url origin'))).toBe(true);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('does not call ghq get -u when repo is already cached', async () => {
|
|
58
|
+
mockExecAsync.mockImplementation((cmd: string) => {
|
|
59
|
+
if (cmd === 'which ghq') return Promise.resolve({ stdout: '/usr/bin/ghq\n', stderr: '' });
|
|
60
|
+
if (cmd === 'ghq root') return Promise.resolve({ stdout: '/home/user/ghq\n', stderr: '' });
|
|
61
|
+
if (cmd.startsWith('test -d')) return Promise.resolve({ stdout: '', stderr: '' });
|
|
62
|
+
if (cmd.startsWith('git clone --local')) return Promise.resolve({ stdout: '', stderr: '' });
|
|
63
|
+
if (cmd.startsWith('git remote set-url')) return Promise.resolve({ stdout: '', stderr: '' });
|
|
64
|
+
return Promise.resolve({ stdout: '', stderr: '' });
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
await cloneWithGhq('git@github.com:acme/platform-app.git', '/workspace/platform-app');
|
|
68
|
+
|
|
69
|
+
const calls = mockExecAsync.mock.calls.map(([cmd]: [string]) => cmd);
|
|
70
|
+
// Should NOT call ghq get since repo is cached
|
|
71
|
+
expect(calls.some((cmd: string) => cmd.startsWith('ghq get'))).toBe(false);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('calls ghq get (without -u) when repo is not cached', async () => {
|
|
75
|
+
mockExecAsync.mockImplementation((cmd: string) => {
|
|
76
|
+
if (cmd === 'which ghq') return Promise.resolve({ stdout: '/usr/bin/ghq\n', stderr: '' });
|
|
77
|
+
if (cmd === 'ghq root') return Promise.resolve({ stdout: '/home/user/ghq\n', stderr: '' });
|
|
78
|
+
if (cmd.startsWith('test -d')) return Promise.reject(new Error('not found'));
|
|
79
|
+
if (cmd.startsWith('ghq get')) return Promise.resolve({ stdout: '', stderr: '' });
|
|
80
|
+
if (cmd.startsWith('git clone --local')) return Promise.resolve({ stdout: '', stderr: '' });
|
|
81
|
+
if (cmd.startsWith('git remote set-url')) return Promise.resolve({ stdout: '', stderr: '' });
|
|
82
|
+
return Promise.resolve({ stdout: '', stderr: '' });
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
await cloneWithGhq('git@github.com:acme/platform-app.git', '/workspace/platform-app');
|
|
86
|
+
|
|
87
|
+
const calls = mockExecAsync.mock.calls.map(([cmd]: [string]) => cmd);
|
|
88
|
+
const ghqGetCalls = calls.filter((cmd: string) => cmd.startsWith('ghq get'));
|
|
89
|
+
expect(ghqGetCalls).toHaveLength(1);
|
|
90
|
+
// Should NOT use -u flag
|
|
91
|
+
expect(ghqGetCalls[0]).not.toContain('-u');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('skips local clone and falls back to direct clone when ghq get fails', async () => {
|
|
95
|
+
mockExecAsync.mockImplementation((cmd: string) => {
|
|
96
|
+
if (cmd === 'which ghq') return Promise.resolve({ stdout: '/usr/bin/ghq\n', stderr: '' });
|
|
97
|
+
if (cmd === 'ghq root') return Promise.resolve({ stdout: '/home/user/ghq\n', stderr: '' });
|
|
98
|
+
if (cmd.startsWith('test -d')) return Promise.reject(new Error('not found'));
|
|
99
|
+
if (cmd.startsWith('ghq get')) return Promise.reject(new Error('network error'));
|
|
100
|
+
if (cmd.startsWith('git clone')) return Promise.resolve({ stdout: '', stderr: '' });
|
|
101
|
+
return Promise.resolve({ stdout: '', stderr: '' });
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
const result = await cloneWithGhq('git@github.com:acme/platform-app.git', '/workspace/platform-app');
|
|
105
|
+
|
|
106
|
+
expect(result.success).toBe(true);
|
|
107
|
+
expect(result.usedGhq).toBe(false);
|
|
108
|
+
|
|
109
|
+
const calls = mockExecAsync.mock.calls.map(([cmd]: [string]) => cmd);
|
|
110
|
+
// Should NOT attempt git clone --local since ghq get failed
|
|
111
|
+
expect(calls.some((cmd: string) => cmd.includes('--local'))).toBe(false);
|
|
112
|
+
// Should fall back to direct clone
|
|
113
|
+
expect(calls.some((cmd: string) => cmd.startsWith('git clone "git@github.com'))).toBe(true);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('falls back to direct git clone when ghq is not installed', async () => {
|
|
117
|
+
mockExecAsync.mockImplementation((cmd: string) => {
|
|
118
|
+
if (cmd === 'which ghq') return Promise.reject(new Error('not found'));
|
|
119
|
+
if (cmd.startsWith('git clone')) return Promise.resolve({ stdout: '', stderr: '' });
|
|
120
|
+
return Promise.resolve({ stdout: '', stderr: '' });
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
const result = await cloneWithGhq('git@github.com:acme/platform-app.git', '/workspace/platform-app');
|
|
124
|
+
|
|
125
|
+
expect(result.success).toBe(true);
|
|
126
|
+
expect(result.usedGhq).toBe(false);
|
|
127
|
+
|
|
128
|
+
const calls = mockExecAsync.mock.calls.map(([cmd]: [string]) => cmd);
|
|
129
|
+
expect(calls.some((cmd: string) => cmd.startsWith('git clone "git@github.com'))).toBe(true);
|
|
130
|
+
expect(calls.some((cmd: string) => cmd.includes('--local'))).toBe(false);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('falls back to direct git clone when local clone fails', async () => {
|
|
134
|
+
mockExecAsync.mockImplementation((cmd: string) => {
|
|
135
|
+
if (cmd === 'which ghq') return Promise.resolve({ stdout: '/usr/bin/ghq\n', stderr: '' });
|
|
136
|
+
if (cmd === 'ghq root') return Promise.resolve({ stdout: '/home/user/ghq\n', stderr: '' });
|
|
137
|
+
if (cmd.startsWith('test -d')) return Promise.resolve({ stdout: '', stderr: '' });
|
|
138
|
+
if (cmd.startsWith('git clone --local')) return Promise.reject(new Error('local clone failed'));
|
|
139
|
+
if (cmd.startsWith('rm -rf')) return Promise.resolve({ stdout: '', stderr: '' });
|
|
140
|
+
if (cmd.startsWith('git clone')) return Promise.resolve({ stdout: '', stderr: '' });
|
|
141
|
+
return Promise.resolve({ stdout: '', stderr: '' });
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
const result = await cloneWithGhq('git@github.com:acme/platform-app.git', '/workspace/platform-app');
|
|
145
|
+
|
|
146
|
+
expect(result.success).toBe(true);
|
|
147
|
+
expect(result.usedGhq).toBe(false);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
describe('describeCloneError', () => {
|
|
152
|
+
it('explains a timeout (killed + SIGTERM) with actionable fixes', () => {
|
|
153
|
+
const err = Object.assign(new Error('Command failed: git clone https://…'), {
|
|
154
|
+
killed: true,
|
|
155
|
+
signal: 'SIGTERM',
|
|
156
|
+
stderr: "Cloning into '/x'...",
|
|
157
|
+
});
|
|
158
|
+
const msg = describeCloneError(err, 15 * 60 * 1000);
|
|
159
|
+
expect(msg).toContain('timed out after 15 min');
|
|
160
|
+
expect(msg).toContain('ghq');
|
|
161
|
+
expect(msg).toContain('WORKSPACE_CLONE_TIMEOUT_MS');
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('explains ETIMEDOUT code as a timeout', () => {
|
|
165
|
+
const err = Object.assign(new Error('boom'), { code: 'ETIMEDOUT' });
|
|
166
|
+
expect(describeCloneError(err, 5 * 60 * 1000)).toContain('timed out after 5 min');
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('explains a maxBuffer kill', () => {
|
|
170
|
+
const err = Object.assign(new Error('boom'), { code: 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER' });
|
|
171
|
+
expect(describeCloneError(err)).toContain('buffer limit');
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('classifies a maxBuffer kill as buffer (not timeout) even when killed=true', () => {
|
|
175
|
+
// Node sets killed=true on maxBuffer kills too; the buffer branch must win.
|
|
176
|
+
const err = Object.assign(new Error('boom'), {
|
|
177
|
+
killed: true,
|
|
178
|
+
signal: 'SIGTERM',
|
|
179
|
+
code: 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER',
|
|
180
|
+
});
|
|
181
|
+
const msg = describeCloneError(err, 15 * 60 * 1000);
|
|
182
|
+
expect(msg).toContain('buffer limit');
|
|
183
|
+
expect(msg).not.toContain('timed out');
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('falls back to message + stderr tail for ordinary failures', () => {
|
|
187
|
+
const err = Object.assign(new Error('fatal: repository not found'), {
|
|
188
|
+
stderr: 'remote: Repository not found.\nfatal: repository not found',
|
|
189
|
+
});
|
|
190
|
+
const msg = describeCloneError(err);
|
|
191
|
+
expect(msg).toContain('fatal: repository not found');
|
|
192
|
+
expect(msg).toContain('git:');
|
|
193
|
+
expect(msg).not.toContain('timed out');
|
|
194
|
+
});
|
|
195
|
+
});
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
import { exec } from 'child_process';
|
|
2
|
+
import { promisify } from 'util';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import { logInfo, logSuccess, logWarning } from './logger';
|
|
5
|
+
import { colorize } from './colors';
|
|
6
|
+
import { CLONE_TIMEOUT_MS, CLONE_MAX_BUFFER } from './config';
|
|
7
|
+
|
|
8
|
+
const execAsync = promisify(exec);
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Turn a raw child-process clone failure (from exec or execFile) into an
|
|
12
|
+
* actionable message. The child API discards the reason on timeout/buffer kill (you just get "Command
|
|
13
|
+
* failed: git clone …"), so we inspect killed/signal/code and the captured
|
|
14
|
+
* stderr to explain what actually happened and how to fix it.
|
|
15
|
+
*/
|
|
16
|
+
export function describeCloneError(error: unknown, timeoutMs: number = CLONE_TIMEOUT_MS): string {
|
|
17
|
+
const e = (error ?? {}) as { message?: string; killed?: boolean; signal?: string; code?: string; stderr?: string };
|
|
18
|
+
const stderr = (e.stderr || '').trim();
|
|
19
|
+
const tail = stderr ? `\n git: ${stderr.split('\n').filter(Boolean).slice(-3).join(' ')}` : '';
|
|
20
|
+
|
|
21
|
+
// Check maxBuffer FIRST: Node sets killed=true on a maxBuffer kill too, so
|
|
22
|
+
// the timeout heuristic below would otherwise shadow this case.
|
|
23
|
+
if (e.code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER') {
|
|
24
|
+
return `Clone output exceeded the buffer limit (very large repo). Install ghq (brew install ghq) for cached clones.${tail}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const timedOut = e.killed === true || e.signal === 'SIGTERM' || e.code === 'ETIMEDOUT';
|
|
28
|
+
if (timedOut) {
|
|
29
|
+
const mins = Math.round(timeoutMs / 60000);
|
|
30
|
+
return `Clone timed out after ${mins} min — the repo may be large or the network slow. `
|
|
31
|
+
+ `Fixes: install ghq for fast cached clones (brew install ghq), switch to SSH (w configure), `
|
|
32
|
+
+ `or raise the limit with WORKSPACE_CLONE_TIMEOUT_MS.${tail}`;
|
|
33
|
+
}
|
|
34
|
+
return `${e.message || 'Unknown error'}${tail}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Check if ghq is installed
|
|
39
|
+
*/
|
|
40
|
+
export async function isGhqInstalled(): Promise<boolean> {
|
|
41
|
+
try {
|
|
42
|
+
await execAsync('which ghq');
|
|
43
|
+
return true;
|
|
44
|
+
} catch {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Verify ghq is installed and, if not, warn with install guidance.
|
|
51
|
+
* ghq gives fast cached clones (shared across workspaces) and avoids the
|
|
52
|
+
* clone-timeout failures large repos hit over plain git-over-HTTPS.
|
|
53
|
+
* Returns true if ghq is available. Non-blocking — clones still work without it.
|
|
54
|
+
*/
|
|
55
|
+
export async function warnIfGhqMissing(): Promise<boolean> {
|
|
56
|
+
if (await isGhqInstalled()) return true;
|
|
57
|
+
logWarning('ghq is not installed — using direct git clone (slower, no shared cache).');
|
|
58
|
+
logInfo(' ghq caches repos so re-cloning across workspaces is near-instant and large repos avoid clone timeouts.');
|
|
59
|
+
logInfo(` Install: ${colorize('brew install ghq', 'cyan')} (macOS) — Linux/Windows: https://github.com/x-motemen/ghq#installation`);
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Get ghq root directory
|
|
65
|
+
*/
|
|
66
|
+
export async function getGhqRoot(): Promise<string | null> {
|
|
67
|
+
try {
|
|
68
|
+
const { stdout } = await execAsync('ghq root');
|
|
69
|
+
return stdout.trim();
|
|
70
|
+
} catch {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Check if a repository exists in ghq
|
|
77
|
+
*/
|
|
78
|
+
export async function ghqRepoExists(repoUrl: string): Promise<boolean> {
|
|
79
|
+
try {
|
|
80
|
+
const repoPath = await getGhqRepoPath(repoUrl);
|
|
81
|
+
if (!repoPath) return false;
|
|
82
|
+
|
|
83
|
+
await execAsync(`test -d "${repoPath}"`);
|
|
84
|
+
return true;
|
|
85
|
+
} catch {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Get the local path for a ghq-managed repository
|
|
92
|
+
*/
|
|
93
|
+
export async function getGhqRepoPath(repoUrl: string): Promise<string | null> {
|
|
94
|
+
try {
|
|
95
|
+
// Extract org/repo from URL
|
|
96
|
+
// e.g., git@github.com:my-org/my-app.git -> github.com/my-org/my-app
|
|
97
|
+
const match = repoUrl.match(/github\.com[:/]([^/]+)\/([^.]+)/);
|
|
98
|
+
if (!match) return null;
|
|
99
|
+
|
|
100
|
+
const [, org, repo] = match;
|
|
101
|
+
const ghqRoot = await getGhqRoot();
|
|
102
|
+
if (!ghqRoot) return null;
|
|
103
|
+
|
|
104
|
+
return path.join(ghqRoot, 'github.com', org, repo);
|
|
105
|
+
} catch {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Clone repository using ghq
|
|
112
|
+
*/
|
|
113
|
+
export async function ghqGet(repoUrl: string): Promise<{ success: boolean; path?: string; error?: string }> {
|
|
114
|
+
try {
|
|
115
|
+
logInfo(`Using ghq to clone ${colorize(repoUrl, 'cyan')}...`);
|
|
116
|
+
|
|
117
|
+
await execAsync(`ghq get "${repoUrl}"`, { timeout: CLONE_TIMEOUT_MS, maxBuffer: CLONE_MAX_BUFFER });
|
|
118
|
+
|
|
119
|
+
const repoPath = await getGhqRepoPath(repoUrl);
|
|
120
|
+
if (!repoPath) {
|
|
121
|
+
return { success: false, error: 'Could not determine ghq path' };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
logSuccess(`Repository available in ghq: ${colorize(repoPath, 'gray')}`);
|
|
125
|
+
|
|
126
|
+
return { success: true, path: repoPath };
|
|
127
|
+
} catch (error) {
|
|
128
|
+
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
129
|
+
return { success: false, error: errorMessage };
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* List all repositories managed by ghq
|
|
135
|
+
*/
|
|
136
|
+
export async function ghqList(): Promise<string[]> {
|
|
137
|
+
try {
|
|
138
|
+
const { stdout } = await execAsync('ghq list');
|
|
139
|
+
return stdout.trim().split('\n').filter(line => line.length > 0);
|
|
140
|
+
} catch {
|
|
141
|
+
return [];
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Display ghq installation instructions
|
|
147
|
+
*/
|
|
148
|
+
export function displayGhqInfo(): void {
|
|
149
|
+
console.log('\n' + '='.repeat(60));
|
|
150
|
+
console.log(colorize('About ghq', 'bright'));
|
|
151
|
+
console.log('='.repeat(60));
|
|
152
|
+
console.log('\nghq is a tool for managing Git repositories in a structured way.');
|
|
153
|
+
console.log('The workspace manager can use ghq for better repository organization.\n');
|
|
154
|
+
console.log('Installation:');
|
|
155
|
+
console.log(' brew install ghq\n');
|
|
156
|
+
console.log('Benefits:');
|
|
157
|
+
console.log(' • Centralized repository cache');
|
|
158
|
+
console.log(' • Consistent directory structure');
|
|
159
|
+
console.log(' • Faster subsequent clones');
|
|
160
|
+
console.log(' • Works alongside workspace isolation\n');
|
|
161
|
+
console.log('After installing ghq, the workspace manager will automatically use it.');
|
|
162
|
+
console.log('='.repeat(60) + '\n');
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Clone repository with ghq integration
|
|
167
|
+
* Falls back to direct git clone if ghq is not available
|
|
168
|
+
*/
|
|
169
|
+
export async function cloneWithGhq(
|
|
170
|
+
repoUrl: string,
|
|
171
|
+
targetPath: string
|
|
172
|
+
): Promise<{ success: boolean; error?: string; usedGhq: boolean }> {
|
|
173
|
+
const hasGhq = await isGhqInstalled();
|
|
174
|
+
|
|
175
|
+
if (hasGhq) {
|
|
176
|
+
// Check if repo already exists in ghq cache
|
|
177
|
+
const ghqPath = await getGhqRepoPath(repoUrl);
|
|
178
|
+
let ghqReady = !!(ghqPath && await ghqRepoExists(repoUrl));
|
|
179
|
+
|
|
180
|
+
if (!ghqReady) {
|
|
181
|
+
// Not in cache — clone into ghq first (without -u to avoid extra fetch)
|
|
182
|
+
const ghqResult = await ghqGet(repoUrl);
|
|
183
|
+
if (ghqResult.success) {
|
|
184
|
+
ghqReady = true;
|
|
185
|
+
} else {
|
|
186
|
+
logWarning(`ghq clone failed: ${ghqResult.error}`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Use git clone --local from ghq cache (hardlinks .git/objects — nearly instant)
|
|
191
|
+
const sourcePath = ghqPath || (await getGhqRepoPath(repoUrl));
|
|
192
|
+
if (ghqReady && sourcePath) {
|
|
193
|
+
try {
|
|
194
|
+
// Refresh the ghq cache BEFORE clone-local so the workspace gets the
|
|
195
|
+
// latest content. Without this, an old cache (cloned weeks/months ago)
|
|
196
|
+
// would serve stale state via hardlinks — user would open a workspace
|
|
197
|
+
// missing dozens of recent commits.
|
|
198
|
+
try {
|
|
199
|
+
await execAsync(
|
|
200
|
+
`git -C "${sourcePath}" fetch origin --quiet --prune`,
|
|
201
|
+
{ timeout: 90 * 1000 },
|
|
202
|
+
);
|
|
203
|
+
} catch {
|
|
204
|
+
// Network failure or no upstream — fall through with stale cache,
|
|
205
|
+
// we'll log a warning after the local clone if we can't align to HEAD.
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
await execAsync(`git clone --local "${sourcePath}" "${targetPath}"`, { timeout: 60 * 1000 });
|
|
209
|
+
// Reset remote to point to the original repo URL (not the local ghq path)
|
|
210
|
+
await execAsync(`git remote set-url origin "${repoUrl}"`, { cwd: targetPath });
|
|
211
|
+
|
|
212
|
+
// Make sure the working tree is at origin's default-branch tip.
|
|
213
|
+
// Robust against:
|
|
214
|
+
// - cache having a non-default branch checked out
|
|
215
|
+
// - fetch above failing (network blip)
|
|
216
|
+
// - refs/remotes/origin/HEAD symref not being set (some git/ghq combos)
|
|
217
|
+
let alignedToHead = false;
|
|
218
|
+
let alignError: string | null = null;
|
|
219
|
+
try {
|
|
220
|
+
await execAsync(`git fetch origin --quiet`, { cwd: targetPath, timeout: 60 * 1000 });
|
|
221
|
+
|
|
222
|
+
// Explicitly set refs/remotes/origin/HEAD by querying the remote.
|
|
223
|
+
// Without this, symbolic-ref may fail if the local symref wasn’t set.
|
|
224
|
+
await execAsync(`git remote set-head origin --auto`, { cwd: targetPath, timeout: 30 * 1000 })
|
|
225
|
+
.catch(() => { /* if this fails the next step might still succeed */ });
|
|
226
|
+
|
|
227
|
+
let defaultBranch: string | null = null;
|
|
228
|
+
try {
|
|
229
|
+
const headRes = await execAsync(
|
|
230
|
+
`git symbolic-ref --short refs/remotes/origin/HEAD`,
|
|
231
|
+
{ cwd: targetPath, timeout: 5000 },
|
|
232
|
+
);
|
|
233
|
+
defaultBranch = headRes.stdout.trim().replace(/^origin\//, '');
|
|
234
|
+
} catch {
|
|
235
|
+
// Fall back to ls-remote query against the actual remote
|
|
236
|
+
try {
|
|
237
|
+
const lsRes = await execAsync(
|
|
238
|
+
`git ls-remote --symref origin HEAD`,
|
|
239
|
+
{ cwd: targetPath, timeout: 30 * 1000 },
|
|
240
|
+
);
|
|
241
|
+
const match = lsRes.stdout.match(/^ref:\s+refs\/heads\/(\S+)\s+HEAD$/m);
|
|
242
|
+
if (match) defaultBranch = match[1];
|
|
243
|
+
} catch { /* fall through to common-name fallback */ }
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Final fallback: try common default branches
|
|
247
|
+
if (!defaultBranch) {
|
|
248
|
+
for (const candidate of ['main', 'master']) {
|
|
249
|
+
const exists = await execAsync(
|
|
250
|
+
`git rev-parse --verify --quiet origin/${candidate}`,
|
|
251
|
+
{ cwd: targetPath, timeout: 5000 },
|
|
252
|
+
).then(() => true).catch(() => false);
|
|
253
|
+
if (exists) { defaultBranch = candidate; break; }
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (!defaultBranch) {
|
|
258
|
+
throw new Error('could not determine default branch (no origin/HEAD, no main, no master)');
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
await execAsync(`git checkout --quiet "${defaultBranch}"`, { cwd: targetPath, timeout: 10 * 1000 });
|
|
262
|
+
await execAsync(`git reset --hard --quiet "origin/${defaultBranch}"`, { cwd: targetPath, timeout: 10 * 1000 });
|
|
263
|
+
alignedToHead = true;
|
|
264
|
+
} catch (error) {
|
|
265
|
+
alignError = error instanceof Error ? error.message : String(error);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (!alignedToHead) {
|
|
269
|
+
// LOUD warning — stale content silently is the worst UX. Surface it.
|
|
270
|
+
logWarning(`⚠️ Could not align ${repoUrl} to origin/HEAD — workspace may have stale content!`);
|
|
271
|
+
if (alignError) logWarning(` reason: ${alignError}`);
|
|
272
|
+
logWarning(` manual fix: cd into the repo and run 'git pull --ff-only'`);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return { success: true, usedGhq: true };
|
|
276
|
+
} catch (error) {
|
|
277
|
+
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
278
|
+
logWarning(`Local clone from ghq failed: ${errorMessage}`);
|
|
279
|
+
// Clean up partial clone before fallback
|
|
280
|
+
try { await execAsync(`rm -rf "${targetPath}"`); } catch { /* ignore */ }
|
|
281
|
+
// Fall through to direct clone
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Fallback to direct git clone
|
|
287
|
+
try {
|
|
288
|
+
await execAsync(`git clone "${repoUrl}" "${targetPath}"`, { timeout: CLONE_TIMEOUT_MS, maxBuffer: CLONE_MAX_BUFFER });
|
|
289
|
+
return { success: true, usedGhq: false };
|
|
290
|
+
} catch (error) {
|
|
291
|
+
return { success: false, error: describeCloneError(error), usedGhq: false };
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Show ghq status
|
|
297
|
+
*/
|
|
298
|
+
export async function getGhqStatus(): Promise<{
|
|
299
|
+
installed: boolean;
|
|
300
|
+
root?: string;
|
|
301
|
+
repoCount?: number;
|
|
302
|
+
}> {
|
|
303
|
+
const installed = await isGhqInstalled();
|
|
304
|
+
|
|
305
|
+
if (!installed) {
|
|
306
|
+
return { installed: false };
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const root = await getGhqRoot();
|
|
310
|
+
const repos = await ghqList();
|
|
311
|
+
|
|
312
|
+
return {
|
|
313
|
+
installed: true,
|
|
314
|
+
root: root || undefined,
|
|
315
|
+
repoCount: repos.length,
|
|
316
|
+
};
|
|
317
|
+
}
|