@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,265 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { cloneRepositories, cloneSingleRepo } from './git-operations';
|
|
3
|
+
import { GitHubRepo } from '../types';
|
|
4
|
+
|
|
5
|
+
// Mock child_process.exec
|
|
6
|
+
const mockExecAsync = vi.fn();
|
|
7
|
+
const mockExecFile = vi.fn();
|
|
8
|
+
vi.mock('child_process', () => ({
|
|
9
|
+
exec: (...args: unknown[]) => {
|
|
10
|
+
const callback = args[args.length - 1] as (err: Error | null, result: { stdout: string; stderr: string }) => void;
|
|
11
|
+
const command = args[0] as string;
|
|
12
|
+
const opts = args.length === 3 ? args[1] : {};
|
|
13
|
+
// Forward to our mockExecAsync which returns a promise
|
|
14
|
+
mockExecAsync(command, opts)
|
|
15
|
+
.then((result: { stdout: string; stderr: string }) => callback(null, result))
|
|
16
|
+
.catch((err: Error) => callback(err, { stdout: '', stderr: '' }));
|
|
17
|
+
},
|
|
18
|
+
execFile: (...args: unknown[]) => {
|
|
19
|
+
const callback = args[args.length - 1] as (err: Error | null, result: { stdout: string; stderr: string }) => void;
|
|
20
|
+
const file = args[0] as string;
|
|
21
|
+
const fileArgs = Array.isArray(args[1]) ? (args[1] as string[]) : [];
|
|
22
|
+
const opts = typeof args[2] === 'object' && args[2] !== null ? args[2] : {};
|
|
23
|
+
// Record the raw argv so tests can assert no-shell/argv separation.
|
|
24
|
+
mockExecFile(file, fileArgs, opts);
|
|
25
|
+
// Forward as a reconstructed command string so existing expectations match.
|
|
26
|
+
mockExecAsync([file, ...fileArgs].join(' '), opts)
|
|
27
|
+
.then((result: { stdout: string; stderr: string }) => callback(null, result))
|
|
28
|
+
.catch((err: Error) => callback(err, { stdout: '', stderr: '' }));
|
|
29
|
+
},
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
vi.mock('fs/promises', () => ({
|
|
33
|
+
rm: vi.fn().mockResolvedValue(undefined),
|
|
34
|
+
}));
|
|
35
|
+
|
|
36
|
+
vi.mock('./logger', () => ({
|
|
37
|
+
logSuccess: vi.fn(),
|
|
38
|
+
logError: vi.fn(),
|
|
39
|
+
logInfo: vi.fn(),
|
|
40
|
+
logWarning: vi.fn(),
|
|
41
|
+
}));
|
|
42
|
+
|
|
43
|
+
vi.mock('./colors', () => ({
|
|
44
|
+
colorize: (text: string) => text,
|
|
45
|
+
}));
|
|
46
|
+
|
|
47
|
+
vi.mock('./ghq-integration', () => ({
|
|
48
|
+
isGhqInstalled: vi.fn().mockResolvedValue(false),
|
|
49
|
+
cloneWithGhq: vi.fn(),
|
|
50
|
+
describeCloneError: (e: { message?: string }) => (e && e.message) || 'Unknown error',
|
|
51
|
+
}));
|
|
52
|
+
|
|
53
|
+
vi.mock('./retry', () => ({
|
|
54
|
+
withRetry: vi.fn(async (fn: () => Promise<unknown>) => fn()),
|
|
55
|
+
}));
|
|
56
|
+
|
|
57
|
+
vi.mock('./progress', () => ({
|
|
58
|
+
createSimpleProgressBar: vi.fn(() => ({
|
|
59
|
+
start: vi.fn(),
|
|
60
|
+
update: vi.fn(),
|
|
61
|
+
stop: vi.fn(),
|
|
62
|
+
})),
|
|
63
|
+
}));
|
|
64
|
+
|
|
65
|
+
function makeRepo(name: string, sshUrl?: string): GitHubRepo {
|
|
66
|
+
return {
|
|
67
|
+
name,
|
|
68
|
+
url: `https://github.com/org/${name}`,
|
|
69
|
+
sshUrl: sshUrl ?? `git@github.com:org/${name}.git`,
|
|
70
|
+
owner: { login: 'org' },
|
|
71
|
+
description: `${name} repo`,
|
|
72
|
+
isPrivate: false,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
describe('cloneRepositories', () => {
|
|
77
|
+
beforeEach(() => {
|
|
78
|
+
vi.clearAllMocks();
|
|
79
|
+
// Default: all exec commands succeed
|
|
80
|
+
mockExecAsync.mockResolvedValue({ stdout: '', stderr: '' });
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('clones unique repos normally', async () => {
|
|
84
|
+
const repoA = makeRepo('repo-a');
|
|
85
|
+
const repoB = makeRepo('repo-b');
|
|
86
|
+
|
|
87
|
+
const results = await cloneRepositories(
|
|
88
|
+
[
|
|
89
|
+
{ repo: repoA, directoryName: 'repo-a' },
|
|
90
|
+
{ repo: repoB, directoryName: 'repo-b' },
|
|
91
|
+
],
|
|
92
|
+
'/workspace'
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
expect(results).toHaveLength(2);
|
|
96
|
+
expect(results.every(r => r.status === 'success')).toBe(true);
|
|
97
|
+
expect(results[0].directoryName).toBe('repo-a');
|
|
98
|
+
expect(results[1].directoryName).toBe('repo-b');
|
|
99
|
+
|
|
100
|
+
// Should have 2 git clone calls (one per unique repo), no --local calls
|
|
101
|
+
const cloneCalls = mockExecAsync.mock.calls.filter(
|
|
102
|
+
([cmd]: [string]) => typeof cmd === 'string' && cmd.includes('git clone')
|
|
103
|
+
);
|
|
104
|
+
expect(cloneCalls).toHaveLength(2);
|
|
105
|
+
const localCalls = cloneCalls.filter(([cmd]: [string]) => cmd.includes('--local'));
|
|
106
|
+
expect(localCalls).toHaveLength(0);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('uses --local for duplicate repos', async () => {
|
|
110
|
+
const repo = makeRepo('platform-app');
|
|
111
|
+
|
|
112
|
+
const results = await cloneRepositories(
|
|
113
|
+
[
|
|
114
|
+
{ repo, directoryName: 'platform-app-1' },
|
|
115
|
+
{ repo, directoryName: 'platform-app-2' },
|
|
116
|
+
{ repo, directoryName: 'platform-app-3' },
|
|
117
|
+
],
|
|
118
|
+
'/workspace'
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
expect(results).toHaveLength(3);
|
|
122
|
+
expect(results.every(r => r.status === 'success')).toBe(true);
|
|
123
|
+
|
|
124
|
+
// First should be a normal clone, 2nd and 3rd via --local
|
|
125
|
+
const cloneCalls = mockExecAsync.mock.calls.filter(
|
|
126
|
+
([cmd]: [string]) => typeof cmd === 'string' && cmd.includes('git clone')
|
|
127
|
+
);
|
|
128
|
+
const normalClones = cloneCalls.filter(([cmd]: [string]) => !cmd.includes('--local'));
|
|
129
|
+
const localClones = cloneCalls.filter(([cmd]: [string]) => cmd.includes('--local'));
|
|
130
|
+
|
|
131
|
+
expect(normalClones).toHaveLength(1);
|
|
132
|
+
expect(localClones).toHaveLength(2);
|
|
133
|
+
|
|
134
|
+
// --local clones should reference the first instance's path as source
|
|
135
|
+
for (const [cmd] of localClones) {
|
|
136
|
+
expect(cmd).toContain('/workspace/platform-app-1');
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('sets correct remote origin on --local clones', async () => {
|
|
141
|
+
const repo = makeRepo('platform-app');
|
|
142
|
+
|
|
143
|
+
await cloneRepositories(
|
|
144
|
+
[
|
|
145
|
+
{ repo, directoryName: 'app-1' },
|
|
146
|
+
{ repo, directoryName: 'app-2' },
|
|
147
|
+
],
|
|
148
|
+
'/workspace'
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
// Should have set-url calls for the --local copies
|
|
152
|
+
const setUrlCalls = mockExecAsync.mock.calls.filter(
|
|
153
|
+
([cmd]: [string]) => typeof cmd === 'string' && cmd.includes('git remote set-url origin')
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
expect(setUrlCalls).toHaveLength(1);
|
|
157
|
+
expect(setUrlCalls[0][0]).toContain(repo.sshUrl);
|
|
158
|
+
expect(setUrlCalls[0][1]).toEqual(expect.objectContaining({ cwd: '/workspace/app-2' }));
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('clones via execFile with separated argv (no shell), preserving spaces in the path', async () => {
|
|
162
|
+
const repo = makeRepo('platform-app');
|
|
163
|
+
mockExecAsync.mockResolvedValue({ stdout: '', stderr: '' });
|
|
164
|
+
|
|
165
|
+
// Directory name with a space would break unquoted shell interpolation.
|
|
166
|
+
const result = await cloneSingleRepo(repo, '/work space', 'dir with space', false);
|
|
167
|
+
|
|
168
|
+
expect(result.status).toBe('success');
|
|
169
|
+
const cloneCall = mockExecFile.mock.calls.find(c => c[1]?.[0] === 'clone');
|
|
170
|
+
expect(cloneCall).toBeDefined();
|
|
171
|
+
expect(cloneCall![0]).toBe('git');
|
|
172
|
+
expect(cloneCall![1]).toEqual(['clone', repo.sshUrl, '/work space/dir with space']);
|
|
173
|
+
// Argv separation means no arg is a joined shell string.
|
|
174
|
+
expect(cloneCall![1].some((a: string) => a.includes(' clone '))).toBe(false);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('marks remaining instances as failed when primary clone fails', async () => {
|
|
178
|
+
const repo = makeRepo('broken-repo');
|
|
179
|
+
|
|
180
|
+
// Make git clone fail
|
|
181
|
+
mockExecAsync.mockRejectedValue(new Error('clone failed'));
|
|
182
|
+
|
|
183
|
+
const results = await cloneRepositories(
|
|
184
|
+
[
|
|
185
|
+
{ repo, directoryName: 'broken-1' },
|
|
186
|
+
{ repo, directoryName: 'broken-2' },
|
|
187
|
+
{ repo, directoryName: 'broken-3' },
|
|
188
|
+
],
|
|
189
|
+
'/workspace'
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
expect(results).toHaveLength(3);
|
|
193
|
+
expect(results.every(r => r.status === 'failed')).toBe(true);
|
|
194
|
+
expect(results[0].error).toBe('clone failed');
|
|
195
|
+
expect(results[1].error).toBe('Primary clone failed');
|
|
196
|
+
expect(results[2].error).toBe('Primary clone failed');
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('handles mix of unique and duplicate repos', async () => {
|
|
200
|
+
const repoA = makeRepo('repo-a');
|
|
201
|
+
const repoB = makeRepo('repo-b');
|
|
202
|
+
|
|
203
|
+
const results = await cloneRepositories(
|
|
204
|
+
[
|
|
205
|
+
{ repo: repoA, directoryName: 'repo-a-1' },
|
|
206
|
+
{ repo: repoA, directoryName: 'repo-a-2' },
|
|
207
|
+
{ repo: repoB, directoryName: 'repo-b-1' },
|
|
208
|
+
],
|
|
209
|
+
'/workspace'
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
expect(results).toHaveLength(3);
|
|
213
|
+
expect(results.every(r => r.status === 'success')).toBe(true);
|
|
214
|
+
|
|
215
|
+
const cloneCalls = mockExecAsync.mock.calls.filter(
|
|
216
|
+
([cmd]: [string]) => typeof cmd === 'string' && cmd.includes('git clone')
|
|
217
|
+
);
|
|
218
|
+
const normalClones = cloneCalls.filter(([cmd]: [string]) => !cmd.includes('--local'));
|
|
219
|
+
const localClones = cloneCalls.filter(([cmd]: [string]) => cmd.includes('--local'));
|
|
220
|
+
|
|
221
|
+
// 2 normal clones (one per unique repo), 1 --local clone for the duplicate
|
|
222
|
+
expect(normalClones).toHaveLength(2);
|
|
223
|
+
expect(localClones).toHaveLength(1);
|
|
224
|
+
expect(localClones[0][0]).toContain('/workspace/repo-a-1');
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('handles --local clone failure independently', async () => {
|
|
228
|
+
const repo = makeRepo('platform-app');
|
|
229
|
+
|
|
230
|
+
// First clone succeeds, --local clone fails
|
|
231
|
+
mockExecAsync
|
|
232
|
+
.mockResolvedValueOnce({ stdout: '', stderr: '' }) // git clone (normal)
|
|
233
|
+
.mockRejectedValueOnce(new Error('local clone failed')); // git clone --local
|
|
234
|
+
|
|
235
|
+
const results = await cloneRepositories(
|
|
236
|
+
[
|
|
237
|
+
{ repo, directoryName: 'app-1' },
|
|
238
|
+
{ repo, directoryName: 'app-2' },
|
|
239
|
+
],
|
|
240
|
+
'/workspace'
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
expect(results).toHaveLength(2);
|
|
244
|
+
expect(results[0].status).toBe('success');
|
|
245
|
+
expect(results[0].directoryName).toBe('app-1');
|
|
246
|
+
expect(results[1].status).toBe('failed');
|
|
247
|
+
expect(results[1].directoryName).toBe('app-2');
|
|
248
|
+
expect(results[1].error).toBe('local clone failed');
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it('returns correct clonedAt timestamp on success', async () => {
|
|
252
|
+
const repo = makeRepo('repo-a');
|
|
253
|
+
|
|
254
|
+
const before = new Date().toISOString();
|
|
255
|
+
const results = await cloneRepositories(
|
|
256
|
+
[{ repo, directoryName: 'repo-a' }],
|
|
257
|
+
'/workspace'
|
|
258
|
+
);
|
|
259
|
+
const after = new Date().toISOString();
|
|
260
|
+
|
|
261
|
+
expect(results[0].clonedAt).toBeDefined();
|
|
262
|
+
expect(results[0].clonedAt! >= before).toBe(true);
|
|
263
|
+
expect(results[0].clonedAt! <= after).toBe(true);
|
|
264
|
+
});
|
|
265
|
+
});
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { exec, execFile } from 'child_process';
|
|
2
|
+
import { promisify } from 'util';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import * as fs from 'fs/promises';
|
|
5
|
+
import { GitHubRepo, CloneResult } from '../types';
|
|
6
|
+
import { logSuccess, logError, logInfo, logWarning } from './logger';
|
|
7
|
+
import { colorize } from './colors';
|
|
8
|
+
import { cloneWithGhq, isGhqInstalled, describeCloneError } from './ghq-integration';
|
|
9
|
+
import { withRetry } from './retry';
|
|
10
|
+
import { createSimpleProgressBar } from './progress';
|
|
11
|
+
import { getCloneUrl, CLONE_TIMEOUT_MS, CLONE_MAX_BUFFER } from './config';
|
|
12
|
+
|
|
13
|
+
const execAsync = promisify(exec);
|
|
14
|
+
const execFileAsync = promisify(execFile);
|
|
15
|
+
|
|
16
|
+
const CONCURRENCY_LIMIT = 3;
|
|
17
|
+
const CLONE_TIMEOUT = CLONE_TIMEOUT_MS;
|
|
18
|
+
|
|
19
|
+
export const cloneSingleRepo = async (
|
|
20
|
+
repo: GitHubRepo,
|
|
21
|
+
workspacePath: string,
|
|
22
|
+
directoryName: string,
|
|
23
|
+
useGhq: boolean = true,
|
|
24
|
+
silent: boolean = false
|
|
25
|
+
): Promise<CloneResult> => {
|
|
26
|
+
const repoPath = path.join(workspacePath, directoryName);
|
|
27
|
+
const cloneUrl = getCloneUrl(repo);
|
|
28
|
+
const displayName = directoryName !== repo.name
|
|
29
|
+
? `${colorize(directoryName, 'cyan')} (${repo.name})`
|
|
30
|
+
: colorize(repo.name, 'cyan');
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
if (!silent) logInfo(`Cloning ${displayName}...`);
|
|
34
|
+
|
|
35
|
+
await withRetry(async () => {
|
|
36
|
+
// Clean up partial clone before retry
|
|
37
|
+
try {
|
|
38
|
+
await fs.rm(repoPath, { recursive: true, force: true });
|
|
39
|
+
} catch {
|
|
40
|
+
// Ignore cleanup errors
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (useGhq) {
|
|
44
|
+
const result = await cloneWithGhq(cloneUrl, repoPath);
|
|
45
|
+
if (!result.success) {
|
|
46
|
+
throw new Error(result.error || 'Clone failed');
|
|
47
|
+
}
|
|
48
|
+
if (!silent) {
|
|
49
|
+
if (result.usedGhq) {
|
|
50
|
+
logSuccess(`Cloned ${displayName} ${colorize('(via ghq)', 'gray')}`);
|
|
51
|
+
} else {
|
|
52
|
+
logSuccess(`Cloned ${displayName}`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
} else {
|
|
56
|
+
try {
|
|
57
|
+
// execFile (no shell) so repo URLs / paths with spaces or shell
|
|
58
|
+
// metacharacters can't break or inject into the command.
|
|
59
|
+
await execFileAsync('git', ['clone', cloneUrl, repoPath], {
|
|
60
|
+
timeout: CLONE_TIMEOUT,
|
|
61
|
+
maxBuffer: CLONE_MAX_BUFFER,
|
|
62
|
+
});
|
|
63
|
+
} catch (err) {
|
|
64
|
+
// execFile loses the reason on timeout/buffer kill — explain it.
|
|
65
|
+
throw new Error(describeCloneError(err, CLONE_TIMEOUT));
|
|
66
|
+
}
|
|
67
|
+
if (!silent) logSuccess(`Cloned ${displayName}`);
|
|
68
|
+
}
|
|
69
|
+
}, undefined, `Clone ${repo.name}`);
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
repo,
|
|
73
|
+
directoryName,
|
|
74
|
+
status: 'success',
|
|
75
|
+
clonedAt: new Date().toISOString(),
|
|
76
|
+
};
|
|
77
|
+
} catch (error) {
|
|
78
|
+
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
79
|
+
logError(`Failed to clone ${displayName}: ${errorMessage}`);
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
repo,
|
|
83
|
+
directoryName,
|
|
84
|
+
status: 'failed',
|
|
85
|
+
error: errorMessage,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
async function cloneLocal(
|
|
91
|
+
repo: GitHubRepo,
|
|
92
|
+
sourcePath: string,
|
|
93
|
+
workspacePath: string,
|
|
94
|
+
directoryName: string
|
|
95
|
+
): Promise<CloneResult> {
|
|
96
|
+
const targetPath = path.join(workspacePath, directoryName);
|
|
97
|
+
try {
|
|
98
|
+
// git clone --local creates hardlinks for .git/objects — nearly instant
|
|
99
|
+
await execAsync(`git clone --local "${sourcePath}" "${targetPath}"`, {
|
|
100
|
+
timeout: CLONE_TIMEOUT,
|
|
101
|
+
maxBuffer: CLONE_MAX_BUFFER,
|
|
102
|
+
});
|
|
103
|
+
// Reset the remote to point to the original repo (not the local source)
|
|
104
|
+
const remoteUrl = getCloneUrl(repo);
|
|
105
|
+
await execAsync(`git remote set-url origin "${remoteUrl}"`, {
|
|
106
|
+
cwd: targetPath,
|
|
107
|
+
});
|
|
108
|
+
return {
|
|
109
|
+
repo,
|
|
110
|
+
directoryName,
|
|
111
|
+
status: 'success',
|
|
112
|
+
clonedAt: new Date().toISOString(),
|
|
113
|
+
};
|
|
114
|
+
} catch (error) {
|
|
115
|
+
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
116
|
+
return {
|
|
117
|
+
repo,
|
|
118
|
+
directoryName,
|
|
119
|
+
status: 'failed',
|
|
120
|
+
error: errorMessage,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export const cloneRepositories = async (
|
|
126
|
+
repoEntries: Array<{ repo: GitHubRepo; directoryName: string }>,
|
|
127
|
+
workspacePath: string
|
|
128
|
+
): Promise<CloneResult[]> => {
|
|
129
|
+
// Check if ghq is available
|
|
130
|
+
const hasGhq = await isGhqInstalled();
|
|
131
|
+
|
|
132
|
+
console.log('\n' + '='.repeat(60));
|
|
133
|
+
console.log(`Cloning ${repoEntries.length} repositories (${CONCURRENCY_LIMIT} concurrent clones)`);
|
|
134
|
+
if (hasGhq) {
|
|
135
|
+
console.log(colorize('Using ghq for efficient repository management', 'cyan'));
|
|
136
|
+
}
|
|
137
|
+
console.log('='.repeat(60) + '\n');
|
|
138
|
+
|
|
139
|
+
const bar = createSimpleProgressBar(repoEntries.length, 'Cloning');
|
|
140
|
+
bar.start(repoEntries.length, 0);
|
|
141
|
+
|
|
142
|
+
// Group entries by repo SSH URL to avoid duplicate clones
|
|
143
|
+
const repoGroups = new Map<string, Array<{ repo: GitHubRepo; directoryName: string }>>();
|
|
144
|
+
for (const entry of repoEntries) {
|
|
145
|
+
const key = entry.repo.sshUrl;
|
|
146
|
+
if (!repoGroups.has(key)) {
|
|
147
|
+
repoGroups.set(key, []);
|
|
148
|
+
}
|
|
149
|
+
repoGroups.get(key)!.push(entry);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const allResults: CloneResult[] = [];
|
|
153
|
+
let completed = 0;
|
|
154
|
+
|
|
155
|
+
// Process unique repo groups with concurrency (different repos clone in parallel)
|
|
156
|
+
const groups = Array.from(repoGroups.values());
|
|
157
|
+
for (let i = 0; i < groups.length; i += CONCURRENCY_LIMIT) {
|
|
158
|
+
const chunk = groups.slice(i, i + CONCURRENCY_LIMIT);
|
|
159
|
+
const chunkPromises = chunk.map(async (entries) => {
|
|
160
|
+
const groupResults: CloneResult[] = [];
|
|
161
|
+
|
|
162
|
+
// Clone first instance normally
|
|
163
|
+
const first = entries[0];
|
|
164
|
+
const firstResult = await cloneSingleRepo(
|
|
165
|
+
first.repo, workspacePath, first.directoryName, hasGhq, true
|
|
166
|
+
);
|
|
167
|
+
groupResults.push(firstResult);
|
|
168
|
+
completed++;
|
|
169
|
+
bar.update(completed);
|
|
170
|
+
|
|
171
|
+
// Clone remaining instances via --local from the first
|
|
172
|
+
if (firstResult.status === 'success' && entries.length > 1) {
|
|
173
|
+
const sourcePath = path.join(workspacePath, first.directoryName);
|
|
174
|
+
for (const entry of entries.slice(1)) {
|
|
175
|
+
const result = await cloneLocal(
|
|
176
|
+
entry.repo, sourcePath, workspacePath, entry.directoryName
|
|
177
|
+
);
|
|
178
|
+
groupResults.push(result);
|
|
179
|
+
completed++;
|
|
180
|
+
bar.update(completed);
|
|
181
|
+
}
|
|
182
|
+
} else if (firstResult.status === 'failed' && entries.length > 1) {
|
|
183
|
+
// Mark remaining as failed too
|
|
184
|
+
for (const entry of entries.slice(1)) {
|
|
185
|
+
groupResults.push({
|
|
186
|
+
repo: entry.repo,
|
|
187
|
+
directoryName: entry.directoryName,
|
|
188
|
+
status: 'failed',
|
|
189
|
+
error: 'Primary clone failed',
|
|
190
|
+
});
|
|
191
|
+
completed++;
|
|
192
|
+
bar.update(completed);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return groupResults;
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
const chunkResults = await Promise.all(chunkPromises);
|
|
200
|
+
for (const groupResults of chunkResults) {
|
|
201
|
+
allResults.push(...groupResults);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
bar.stop();
|
|
206
|
+
console.log('');
|
|
207
|
+
|
|
208
|
+
return allResults;
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
export const reportCloneResults = (results: CloneResult[]): void => {
|
|
212
|
+
const successful = results.filter(r => r.status === 'success');
|
|
213
|
+
const failed = results.filter(r => r.status === 'failed');
|
|
214
|
+
|
|
215
|
+
console.log('\n' + '='.repeat(60));
|
|
216
|
+
console.log('Clone Results Summary');
|
|
217
|
+
console.log('='.repeat(60));
|
|
218
|
+
console.log(`${colorize('✓', 'green')} Successful: ${successful.length}`);
|
|
219
|
+
console.log(`${colorize('✗', 'red')} Failed: ${failed.length}`);
|
|
220
|
+
console.log('='.repeat(60));
|
|
221
|
+
|
|
222
|
+
if (failed.length > 0) {
|
|
223
|
+
console.log('\nFailed repositories:');
|
|
224
|
+
failed.forEach(result => {
|
|
225
|
+
const displayName = result.directoryName !== result.repo.name
|
|
226
|
+
? `${result.directoryName} (${result.repo.name})`
|
|
227
|
+
: result.repo.name;
|
|
228
|
+
console.log(` ${colorize('✗', 'red')} ${displayName}: ${result.error}`);
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
console.log('');
|
|
233
|
+
};
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { exec } from 'child_process';
|
|
2
|
+
import { promisify } from 'util';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import { GitStatus } from '../types';
|
|
5
|
+
|
|
6
|
+
const execAsync = promisify(exec);
|
|
7
|
+
const GIT_TIMEOUT = 10000; // 10 seconds per git operation
|
|
8
|
+
|
|
9
|
+
export const getGitStatus = async (repoPath: string, repoName: string): Promise<GitStatus> => {
|
|
10
|
+
try {
|
|
11
|
+
const results = await Promise.allSettled([
|
|
12
|
+
getCurrentBranch(repoPath),
|
|
13
|
+
getUncommittedChanges(repoPath),
|
|
14
|
+
getAheadBehindCounts(repoPath),
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
const branch = results[0].status === 'fulfilled' ? results[0].value : 'unknown';
|
|
18
|
+
const changes = results[1].status === 'fulfilled' ? results[1].value : { clean: false, modifiedFiles: 0, untrackedFiles: 0 };
|
|
19
|
+
const tracking = results[2].status === 'fulfilled' ? results[2].value : { ahead: 0, behind: 0, hasRemote: false };
|
|
20
|
+
|
|
21
|
+
return {
|
|
22
|
+
repo: repoName,
|
|
23
|
+
branch,
|
|
24
|
+
clean: changes.clean,
|
|
25
|
+
ahead: tracking.ahead,
|
|
26
|
+
behind: tracking.behind,
|
|
27
|
+
modifiedFiles: changes.modifiedFiles,
|
|
28
|
+
untrackedFiles: changes.untrackedFiles,
|
|
29
|
+
hasRemote: tracking.hasRemote,
|
|
30
|
+
detachedHead: branch.startsWith('HEAD detached'),
|
|
31
|
+
};
|
|
32
|
+
} catch (error) {
|
|
33
|
+
// Return error state
|
|
34
|
+
return {
|
|
35
|
+
repo: repoName,
|
|
36
|
+
branch: 'unknown',
|
|
37
|
+
clean: false,
|
|
38
|
+
ahead: 0,
|
|
39
|
+
behind: 0,
|
|
40
|
+
modifiedFiles: 0,
|
|
41
|
+
untrackedFiles: 0,
|
|
42
|
+
hasRemote: false,
|
|
43
|
+
detachedHead: false,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export const getCurrentBranch = async (repoPath: string): Promise<string> => {
|
|
49
|
+
try {
|
|
50
|
+
const { stdout } = await execAsync('git rev-parse --abbrev-ref HEAD', {
|
|
51
|
+
cwd: repoPath,
|
|
52
|
+
timeout: GIT_TIMEOUT,
|
|
53
|
+
});
|
|
54
|
+
const branch = stdout.trim();
|
|
55
|
+
|
|
56
|
+
// Check for detached HEAD
|
|
57
|
+
if (branch === 'HEAD') {
|
|
58
|
+
const { stdout: commitHash } = await execAsync('git rev-parse --short HEAD', {
|
|
59
|
+
cwd: repoPath,
|
|
60
|
+
timeout: GIT_TIMEOUT,
|
|
61
|
+
});
|
|
62
|
+
return `HEAD detached at ${commitHash.trim()}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return branch;
|
|
66
|
+
} catch (error) {
|
|
67
|
+
return 'unknown';
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export const getUncommittedChanges = async (
|
|
72
|
+
repoPath: string
|
|
73
|
+
): Promise<{ clean: boolean; modifiedFiles: number; untrackedFiles: number }> => {
|
|
74
|
+
try {
|
|
75
|
+
const { stdout } = await execAsync('git status --porcelain', {
|
|
76
|
+
cwd: repoPath,
|
|
77
|
+
timeout: GIT_TIMEOUT,
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
if (!stdout.trim()) {
|
|
81
|
+
return { clean: true, modifiedFiles: 0, untrackedFiles: 0 };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const lines = stdout.trim().split('\n');
|
|
85
|
+
let modifiedFiles = 0;
|
|
86
|
+
let untrackedFiles = 0;
|
|
87
|
+
|
|
88
|
+
for (const line of lines) {
|
|
89
|
+
const status = line.substring(0, 2);
|
|
90
|
+
if (status.includes('?')) {
|
|
91
|
+
untrackedFiles++;
|
|
92
|
+
} else {
|
|
93
|
+
modifiedFiles++;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return { clean: false, modifiedFiles, untrackedFiles };
|
|
98
|
+
} catch (error) {
|
|
99
|
+
return { clean: false, modifiedFiles: 0, untrackedFiles: 0 };
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
export const getAheadBehindCounts = async (
|
|
104
|
+
repoPath: string
|
|
105
|
+
): Promise<{ ahead: number; behind: number; hasRemote: boolean }> => {
|
|
106
|
+
try {
|
|
107
|
+
// First check if there's a remote tracking branch
|
|
108
|
+
const { stdout: upstreamBranch } = await execAsync('git rev-parse --abbrev-ref @{upstream}', {
|
|
109
|
+
cwd: repoPath,
|
|
110
|
+
timeout: GIT_TIMEOUT,
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
if (!upstreamBranch.trim()) {
|
|
114
|
+
return { ahead: 0, behind: 0, hasRemote: false };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Get ahead/behind counts
|
|
118
|
+
const { stdout } = await execAsync('git rev-list --count --left-right @{upstream}...HEAD', {
|
|
119
|
+
cwd: repoPath,
|
|
120
|
+
timeout: GIT_TIMEOUT,
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
const counts = stdout.trim().split('\t');
|
|
124
|
+
const behind = parseInt(counts[0], 10) || 0;
|
|
125
|
+
const ahead = parseInt(counts[1], 10) || 0;
|
|
126
|
+
|
|
127
|
+
return { ahead, behind, hasRemote: true };
|
|
128
|
+
} catch (error) {
|
|
129
|
+
// No remote tracking branch
|
|
130
|
+
return { ahead: 0, behind: 0, hasRemote: false };
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
export const getAllReposStatus = async (
|
|
135
|
+
workspacePath: string,
|
|
136
|
+
repoNames: string[],
|
|
137
|
+
concurrency: number = 3
|
|
138
|
+
): Promise<GitStatus[]> => {
|
|
139
|
+
const results: GitStatus[] = [];
|
|
140
|
+
|
|
141
|
+
// Process in chunks for controlled parallelism
|
|
142
|
+
for (let i = 0; i < repoNames.length; i += concurrency) {
|
|
143
|
+
const chunk = repoNames.slice(i, i + concurrency);
|
|
144
|
+
const chunkResults = await Promise.all(
|
|
145
|
+
chunk.map(repoName => {
|
|
146
|
+
const repoPath = path.join(workspacePath, repoName);
|
|
147
|
+
return getGitStatus(repoPath, repoName);
|
|
148
|
+
})
|
|
149
|
+
);
|
|
150
|
+
results.push(...chunkResults);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return results;
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
export const hasUncommittedChanges = async (repoPath: string): Promise<boolean> => {
|
|
157
|
+
const { clean } = await getUncommittedChanges(repoPath);
|
|
158
|
+
return !clean;
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
export const isGitRepository = async (repoPath: string): Promise<boolean> => {
|
|
162
|
+
try {
|
|
163
|
+
await execAsync('git rev-parse --git-dir', {
|
|
164
|
+
cwd: repoPath,
|
|
165
|
+
timeout: GIT_TIMEOUT,
|
|
166
|
+
});
|
|
167
|
+
return true;
|
|
168
|
+
} catch (error) {
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
};
|