@getforgeai/cli 0.1.0-beta.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/LICENSE +201 -0
- package/README.md +80 -0
- package/dist/bin/forgeai.d.ts +3 -0
- package/dist/bin/forgeai.js +8 -0
- package/dist/cli.d.ts +4 -0
- package/dist/cli.js +180 -0
- package/dist/commands/auth-login.d.ts +4 -0
- package/dist/commands/auth-login.js +202 -0
- package/dist/commands/auth-logout.d.ts +2 -0
- package/dist/commands/auth-logout.js +41 -0
- package/dist/commands/auth-status.d.ts +4 -0
- package/dist/commands/auth-status.js +81 -0
- package/dist/commands/cli-list.d.ts +4 -0
- package/dist/commands/cli-list.js +40 -0
- package/dist/commands/cli-unlink.d.ts +4 -0
- package/dist/commands/cli-unlink.js +45 -0
- package/dist/commands/config-set.d.ts +5 -0
- package/dist/commands/config-set.js +63 -0
- package/dist/commands/connect.d.ts +9 -0
- package/dist/commands/connect.js +165 -0
- package/dist/commands/disconnect.d.ts +5 -0
- package/dist/commands/disconnect.js +126 -0
- package/dist/commands/journal-clear.d.ts +2 -0
- package/dist/commands/journal-clear.js +16 -0
- package/dist/commands/journal-list.d.ts +2 -0
- package/dist/commands/journal-list.js +20 -0
- package/dist/commands/journal-status.d.ts +2 -0
- package/dist/commands/journal-status.js +18 -0
- package/dist/commands/mcp-serve.d.ts +13 -0
- package/dist/commands/mcp-serve.js +74 -0
- package/dist/commands/org-list.d.ts +4 -0
- package/dist/commands/org-list.js +56 -0
- package/dist/commands/org-unlink.d.ts +4 -0
- package/dist/commands/org-unlink.js +63 -0
- package/dist/commands/reconnect.d.ts +4 -0
- package/dist/commands/reconnect.js +46 -0
- package/dist/commands/release-open.d.ts +2 -0
- package/dist/commands/release-open.js +43 -0
- package/dist/commands/session-respond.d.ts +2 -0
- package/dist/commands/session-respond.js +29 -0
- package/dist/commands/task-open.d.ts +2 -0
- package/dist/commands/task-open.js +42 -0
- package/dist/commands/vm.d.ts +21 -0
- package/dist/commands/vm.js +122 -0
- package/dist/commands/workflow-open.d.ts +2 -0
- package/dist/commands/workflow-open.js +43 -0
- package/dist/config/config-manager.d.ts +31 -0
- package/dist/config/config-manager.js +70 -0
- package/dist/config/token-manager.d.ts +11 -0
- package/dist/config/token-manager.js +87 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +22 -0
- package/dist/lib/auth-client.d.ts +1670 -0
- package/dist/lib/auth-client.js +10 -0
- package/dist/lib/branch-manager.d.ts +79 -0
- package/dist/lib/branch-manager.js +620 -0
- package/dist/lib/clone-and-checkout.d.ts +6 -0
- package/dist/lib/clone-and-checkout.js +41 -0
- package/dist/lib/connection-manager.d.ts +58 -0
- package/dist/lib/connection-manager.js +436 -0
- package/dist/lib/daemon-entry.d.ts +2 -0
- package/dist/lib/daemon-entry.js +210 -0
- package/dist/lib/daemon.d.ts +38 -0
- package/dist/lib/daemon.js +148 -0
- package/dist/lib/event-journal.d.ts +24 -0
- package/dist/lib/event-journal.js +76 -0
- package/dist/lib/ipc-client.d.ts +10 -0
- package/dist/lib/ipc-client.js +70 -0
- package/dist/lib/ipc-server.d.ts +36 -0
- package/dist/lib/ipc-server.js +70 -0
- package/dist/lib/node-version-guard.d.ts +8 -0
- package/dist/lib/node-version-guard.js +60 -0
- package/dist/lib/open-directory.d.ts +7 -0
- package/dist/lib/open-directory.js +41 -0
- package/dist/lib/repo-manager.d.ts +29 -0
- package/dist/lib/repo-manager.js +103 -0
- package/dist/lib/resilient-emitter.d.ts +53 -0
- package/dist/lib/resilient-emitter.js +160 -0
- package/dist/lib/rest-client.d.ts +164 -0
- package/dist/lib/rest-client.js +139 -0
- package/dist/lib/skill-manager.d.ts +23 -0
- package/dist/lib/skill-manager.js +167 -0
- package/dist/lib/socket-client.d.ts +68 -0
- package/dist/lib/socket-client.js +160 -0
- package/dist/mcp/mcp-probe.d.ts +11 -0
- package/dist/mcp/mcp-probe.js +106 -0
- package/dist/mcp/mcp-response.d.ts +29 -0
- package/dist/mcp/mcp-response.js +67 -0
- package/dist/mcp/mcp-server.d.ts +19 -0
- package/dist/mcp/mcp-server.js +76 -0
- package/dist/mcp/resources/project-conventions.d.ts +4 -0
- package/dist/mcp/resources/project-conventions.js +50 -0
- package/dist/mcp/resources/task-current.d.ts +4 -0
- package/dist/mcp/resources/task-current.js +51 -0
- package/dist/mcp/tools/forge-add-group-dependency.d.ts +4 -0
- package/dist/mcp/tools/forge-add-group-dependency.js +20 -0
- package/dist/mcp/tools/forge-add-task-dependency.d.ts +4 -0
- package/dist/mcp/tools/forge-add-task-dependency.js +20 -0
- package/dist/mcp/tools/forge-ask-human.d.ts +4 -0
- package/dist/mcp/tools/forge-ask-human.js +80 -0
- package/dist/mcp/tools/forge-column-done.d.ts +4 -0
- package/dist/mcp/tools/forge-column-done.js +46 -0
- package/dist/mcp/tools/forge-complete-step.d.ts +4 -0
- package/dist/mcp/tools/forge-complete-step.js +139 -0
- package/dist/mcp/tools/forge-create-task-group.d.ts +4 -0
- package/dist/mcp/tools/forge-create-task-group.js +22 -0
- package/dist/mcp/tools/forge-create-task.d.ts +4 -0
- package/dist/mcp/tools/forge-create-task.js +28 -0
- package/dist/mcp/tools/forge-delete-task-group.d.ts +4 -0
- package/dist/mcp/tools/forge-delete-task-group.js +17 -0
- package/dist/mcp/tools/forge-delete-task.d.ts +4 -0
- package/dist/mcp/tools/forge-delete-task.js +17 -0
- package/dist/mcp/tools/forge-get-task.d.ts +4 -0
- package/dist/mcp/tools/forge-get-task.js +17 -0
- package/dist/mcp/tools/forge-merge-back.d.ts +4 -0
- package/dist/mcp/tools/forge-merge-back.js +179 -0
- package/dist/mcp/tools/forge-release-merge.d.ts +4 -0
- package/dist/mcp/tools/forge-release-merge.js +159 -0
- package/dist/mcp/tools/forge-release-notes.d.ts +4 -0
- package/dist/mcp/tools/forge-release-notes.js +51 -0
- package/dist/mcp/tools/forge-release-tag.d.ts +4 -0
- package/dist/mcp/tools/forge-release-tag.js +125 -0
- package/dist/mcp/tools/forge-remove-group-dependency.d.ts +4 -0
- package/dist/mcp/tools/forge-remove-group-dependency.js +19 -0
- package/dist/mcp/tools/forge-remove-task-dependency.d.ts +4 -0
- package/dist/mcp/tools/forge-remove-task-dependency.js +19 -0
- package/dist/mcp/tools/forge-requeue.d.ts +4 -0
- package/dist/mcp/tools/forge-requeue.js +39 -0
- package/dist/mcp/tools/forge-submit-pr.d.ts +4 -0
- package/dist/mcp/tools/forge-submit-pr.js +68 -0
- package/dist/mcp/tools/forge-update-status.d.ts +4 -0
- package/dist/mcp/tools/forge-update-status.js +77 -0
- package/dist/mcp/tools/forge-update-task-group.d.ts +4 -0
- package/dist/mcp/tools/forge-update-task-group.js +21 -0
- package/dist/mcp/tools/forge-update-task.d.ts +4 -0
- package/dist/mcp/tools/forge-update-task.js +25 -0
- package/dist/mcp/tools/forge-validate-merge.d.ts +4 -0
- package/dist/mcp/tools/forge-validate-merge.js +194 -0
- package/dist/mcp/types.d.ts +39 -0
- package/dist/mcp/types.js +8 -0
- package/dist/methodology/artifact-manager.d.ts +14 -0
- package/dist/methodology/artifact-manager.js +55 -0
- package/dist/orchestrator/agent-registry.d.ts +53 -0
- package/dist/orchestrator/agent-registry.js +96 -0
- package/dist/orchestrator/agent-spawner.d.ts +112 -0
- package/dist/orchestrator/agent-spawner.js +1518 -0
- package/dist/orchestrator/connectors/claude-code-connector.d.ts +3 -0
- package/dist/orchestrator/connectors/claude-code-connector.js +60 -0
- package/dist/orchestrator/connectors/connector.d.ts +95 -0
- package/dist/orchestrator/connectors/connector.js +25 -0
- package/dist/orchestrator/connectors/docker-connector.d.ts +14 -0
- package/dist/orchestrator/connectors/docker-connector.js +337 -0
- package/dist/orchestrator/connectors/native-vm-connector.d.ts +11 -0
- package/dist/orchestrator/connectors/native-vm-connector.js +162 -0
- package/dist/orchestrator/connectors/opencode-connector.d.ts +3 -0
- package/dist/orchestrator/connectors/opencode-connector.js +39 -0
- package/dist/orchestrator/connectors/vm-connector.d.ts +3 -0
- package/dist/orchestrator/connectors/vm-connector.js +139 -0
- package/dist/orchestrator/dispatch-queue.d.ts +43 -0
- package/dist/orchestrator/dispatch-queue.js +93 -0
- package/dist/orchestrator/resume-handler.d.ts +34 -0
- package/dist/orchestrator/resume-handler.js +159 -0
- package/dist/orchestrator/stream-batcher.d.ts +16 -0
- package/dist/orchestrator/stream-batcher.js +50 -0
- package/dist/orchestrator/stream-json-extractor.d.ts +97 -0
- package/dist/orchestrator/stream-json-extractor.js +579 -0
- package/dist/vm/claude-token-manager.d.ts +36 -0
- package/dist/vm/claude-token-manager.js +223 -0
- package/dist/vm/docker-executor.d.ts +131 -0
- package/dist/vm/docker-executor.js +360 -0
- package/dist/vm/docker-process-proxy.d.ts +31 -0
- package/dist/vm/docker-process-proxy.js +105 -0
- package/dist/vm/forge-vm-agent.d.ts +11 -0
- package/dist/vm/forge-vm-agent.js +159 -0
- package/dist/vm/git-credentials.d.ts +43 -0
- package/dist/vm/git-credentials.js +53 -0
- package/dist/vm/image-manager.d.ts +7 -0
- package/dist/vm/image-manager.js +61 -0
- package/dist/vm/log.d.ts +9 -0
- package/dist/vm/log.js +18 -0
- package/dist/vm/mcp-bridge.d.ts +63 -0
- package/dist/vm/mcp-bridge.js +243 -0
- package/dist/vm/mcp-stub.d.ts +15 -0
- package/dist/vm/mcp-stub.js +67 -0
- package/dist/vm/native-addon.d.ts +69 -0
- package/dist/vm/native-addon.js +68 -0
- package/dist/vm/provider-resolver.d.ts +7 -0
- package/dist/vm/provider-resolver.js +27 -0
- package/dist/vm/providers/lima.d.ts +17 -0
- package/dist/vm/providers/lima.js +191 -0
- package/dist/vm/providers/qemu.d.ts +28 -0
- package/dist/vm/providers/qemu.js +260 -0
- package/dist/vm/providers/vfkit.d.ts +40 -0
- package/dist/vm/providers/vfkit.js +371 -0
- package/dist/vm/providers/wsl2.d.ts +27 -0
- package/dist/vm/providers/wsl2.js +226 -0
- package/dist/vm/session-manager.d.ts +130 -0
- package/dist/vm/session-manager.js +494 -0
- package/dist/vm/session-snapshot.d.ts +31 -0
- package/dist/vm/session-snapshot.js +180 -0
- package/dist/vm/types.d.ts +73 -0
- package/dist/vm/types.js +2 -0
- package/dist/vm/vm-helper-client.d.ts +81 -0
- package/dist/vm/vm-helper-client.js +335 -0
- package/dist/vm/vm-helper.d.ts +11 -0
- package/dist/vm/vm-helper.js +156 -0
- package/dist/vm/vm-ipc-protocol.d.ts +84 -0
- package/dist/vm/vm-ipc-protocol.js +9 -0
- package/dist/vm/vm-manager-facade.d.ts +23 -0
- package/dist/vm/vm-manager-facade.js +43 -0
- package/dist/vm/vm-manager.d.ts +74 -0
- package/dist/vm/vm-manager.js +515 -0
- package/dist/vm/vm-process-proxy.d.ts +34 -0
- package/dist/vm/vm-process-proxy.js +64 -0
- package/dist/vm/vsock-bridge.d.ts +44 -0
- package/dist/vm/vsock-bridge.js +140 -0
- package/dist/vm/vsock-protocol.d.ts +98 -0
- package/dist/vm/vsock-protocol.js +57 -0
- package/package.json +65 -0
- package/rootfs/Dockerfile +50 -0
- package/rootfs/forge-mcp-relay.mjs +158 -0
|
@@ -0,0 +1,620 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { existsSync, statSync } from "node:fs";
|
|
3
|
+
import { promisify } from "node:util";
|
|
4
|
+
import chalk from "chalk";
|
|
5
|
+
import { Mutex } from "async-mutex";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
/**
|
|
9
|
+
* Resolve the local repo path for a project.
|
|
10
|
+
* Clone if missing, fetch if existing. Used for branch operations on the host
|
|
11
|
+
* (listing branches, creating workflow branches) before a VM is booted.
|
|
12
|
+
*/
|
|
13
|
+
async function resolveRepoPath(projectSlug, repoUrl) {
|
|
14
|
+
if (!repoUrl) {
|
|
15
|
+
console.warn(chalk.yellow("[branch] No repoUrl provided, falling back to process.cwd()"));
|
|
16
|
+
return process.cwd();
|
|
17
|
+
}
|
|
18
|
+
const VALID_GIT_URL = /^(https?:\/\/[\w.-]+\w(\/|$)|git@\w[\w.-]*:)/;
|
|
19
|
+
const targetDir = join(homedir(), ".forgeai", "repos", projectSlug);
|
|
20
|
+
const mutex = getRepoMutex(targetDir);
|
|
21
|
+
return mutex.runExclusive(async () => {
|
|
22
|
+
const gitPath = join(targetDir, ".git");
|
|
23
|
+
const repoExists = existsSync(gitPath) && statSync(gitPath).isDirectory();
|
|
24
|
+
if (repoExists) {
|
|
25
|
+
try {
|
|
26
|
+
await execFileAsync("git", ["fetch", "--all"], {
|
|
27
|
+
cwd: targetDir,
|
|
28
|
+
timeout: 60_000,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
// Non-fatal fetch failure
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
if (!VALID_GIT_URL.test(repoUrl)) {
|
|
37
|
+
throw new Error(`Invalid git URL format: ${repoUrl}`);
|
|
38
|
+
}
|
|
39
|
+
await execFileAsync("git", ["clone", repoUrl, targetDir], {
|
|
40
|
+
timeout: 120_000,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
return targetDir;
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
const execFileAsync = promisify(execFile);
|
|
47
|
+
// Per-repo mutex to serialize git worktree operations (AC: #6)
|
|
48
|
+
const repoMutexes = new Map();
|
|
49
|
+
function getRepoMutex(repoPath) {
|
|
50
|
+
let mutex = repoMutexes.get(repoPath);
|
|
51
|
+
if (!mutex) {
|
|
52
|
+
mutex = new Mutex();
|
|
53
|
+
repoMutexes.set(repoPath, mutex);
|
|
54
|
+
}
|
|
55
|
+
return mutex;
|
|
56
|
+
}
|
|
57
|
+
function getProjectSlugOrDefault(payload) {
|
|
58
|
+
if (!payload.projectSlug) {
|
|
59
|
+
console.error(chalk.red('[branch] Missing projectSlug in payload, using "default" — this WILL cause repo path conflicts if multiple projects are active. Ensure Cloud sends projectSlug in all event payloads.'));
|
|
60
|
+
return "default";
|
|
61
|
+
}
|
|
62
|
+
return payload.projectSlug;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Check if a repository has zero commits (freshly created / empty).
|
|
66
|
+
*/
|
|
67
|
+
async function isRepoEmpty(repoPath) {
|
|
68
|
+
try {
|
|
69
|
+
await execFileAsync("git", ["rev-parse", "HEAD"], { cwd: repoPath });
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Initialize an empty repository with an initial commit and push the source branch.
|
|
78
|
+
* Used for greenfield projects where the remote repo was just created (no commits).
|
|
79
|
+
*
|
|
80
|
+
* Must be called inside the repo mutex (callers already hold it).
|
|
81
|
+
*/
|
|
82
|
+
async function initializeSourceBranch(repoPath, sourceBranch) {
|
|
83
|
+
try {
|
|
84
|
+
// Create an initial empty commit
|
|
85
|
+
await execFileAsync("git", ["commit", "--allow-empty", "-m", "Initial commit"], { cwd: repoPath });
|
|
86
|
+
// Ensure we're on the expected source branch name
|
|
87
|
+
const { stdout: currentBranch } = await execFileAsync("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd: repoPath });
|
|
88
|
+
if (currentBranch.trim() !== sourceBranch) {
|
|
89
|
+
await execFileAsync("git", ["branch", "-M", sourceBranch], {
|
|
90
|
+
cwd: repoPath,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
// Push the source branch to remote
|
|
94
|
+
await execFileAsync("git", ["push", "-u", "origin", sourceBranch], {
|
|
95
|
+
cwd: repoPath,
|
|
96
|
+
});
|
|
97
|
+
console.log(chalk.green(`[branch] Initialized empty repo: created and pushed "${sourceBranch}"`));
|
|
98
|
+
return { success: true };
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
102
|
+
console.error(chalk.red(`[branch] Failed to initialize empty repo: ${message}`));
|
|
103
|
+
return { success: false, error: message };
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* List remote branches for a git repository at the given path.
|
|
108
|
+
* Returns branch names (short format, without origin/ prefix) and the default branch.
|
|
109
|
+
*/
|
|
110
|
+
export async function listBranches(repoPath) {
|
|
111
|
+
const { stdout: branchOutput } = await execFileAsync("git", ["branch", "-r", "--format=%(refname:short)"], { cwd: repoPath });
|
|
112
|
+
const branches = branchOutput
|
|
113
|
+
.split("\n")
|
|
114
|
+
.map((b) => b.trim())
|
|
115
|
+
.filter((b) => b.length > 0 && !b.includes("HEAD"))
|
|
116
|
+
.map((b) => b.replace(/^origin\//, ""));
|
|
117
|
+
let defaultBranch = "main";
|
|
118
|
+
try {
|
|
119
|
+
const { stdout: headRef } = await execFileAsync("git", ["symbolic-ref", "refs/remotes/origin/HEAD"], { cwd: repoPath });
|
|
120
|
+
const ref = headRef.trim();
|
|
121
|
+
// refs/remotes/origin/main → main
|
|
122
|
+
const match = /refs\/remotes\/origin\/(.+)/.exec(ref);
|
|
123
|
+
if (match?.[1]) {
|
|
124
|
+
defaultBranch = match[1];
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
// Fallback: if HEAD ref is not set, try to detect main/master
|
|
129
|
+
if (branches.includes("main")) {
|
|
130
|
+
defaultBranch = "main";
|
|
131
|
+
}
|
|
132
|
+
else if (branches.includes("master")) {
|
|
133
|
+
defaultBranch = "master";
|
|
134
|
+
}
|
|
135
|
+
else if (branches.length > 0) {
|
|
136
|
+
defaultBranch = branches[0];
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return { branches, defaultBranch };
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Create a workflow branch from a source branch.
|
|
143
|
+
* Idempotent: if the branch already exists from the same source, reports success.
|
|
144
|
+
*
|
|
145
|
+
* Steps:
|
|
146
|
+
* 1. Fetch the latest source branch from remote (ensures it's up-to-date)
|
|
147
|
+
* 2. Create the workflow branch from `origin/<sourceBranch>`
|
|
148
|
+
* 3. Push the workflow branch to remote with tracking (`-u`)
|
|
149
|
+
*/
|
|
150
|
+
export async function createWorkflowBranch(repoPath, sourceBranch, baseBranch) {
|
|
151
|
+
const mutex = getRepoMutex(repoPath);
|
|
152
|
+
return mutex.runExclusive(async () => {
|
|
153
|
+
try {
|
|
154
|
+
// Check if branch already exists
|
|
155
|
+
try {
|
|
156
|
+
const { stdout } = await execFileAsync("git", ["rev-parse", "--verify", baseBranch], { cwd: repoPath });
|
|
157
|
+
if (stdout.trim()) {
|
|
158
|
+
// Branch exists — check if it was created from the same source
|
|
159
|
+
const { stdout: baseCommit } = await execFileAsync("git", ["merge-base", baseBranch, sourceBranch], { cwd: repoPath });
|
|
160
|
+
const { stdout: sourceHead } = await execFileAsync("git", ["rev-parse", sourceBranch], { cwd: repoPath });
|
|
161
|
+
// If merge-base equals source HEAD, the branch was created from this source
|
|
162
|
+
if (baseCommit.trim() === sourceHead.trim()) {
|
|
163
|
+
console.log(chalk.green(`[branch] Branch "${baseBranch}" already exists from "${sourceBranch}" — idempotent success`));
|
|
164
|
+
return { success: true };
|
|
165
|
+
}
|
|
166
|
+
return {
|
|
167
|
+
success: false,
|
|
168
|
+
error: `Branch "${baseBranch}" already exists but was not created from "${sourceBranch}"`,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
// Branch doesn't exist — this is the expected path, continue to create
|
|
174
|
+
}
|
|
175
|
+
// Fetch latest source branch from remote to ensure we branch
|
|
176
|
+
// from the most recent state
|
|
177
|
+
let baseRef = sourceBranch;
|
|
178
|
+
try {
|
|
179
|
+
await execFileAsync("git", ["fetch", "origin", sourceBranch], {
|
|
180
|
+
cwd: repoPath,
|
|
181
|
+
});
|
|
182
|
+
baseRef = `origin/${sourceBranch}`;
|
|
183
|
+
console.log(chalk.blue(`[branch] Fetched latest "${sourceBranch}" from remote`));
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
// Source branch not on remote — check if repo is empty (greenfield)
|
|
187
|
+
if (await isRepoEmpty(repoPath)) {
|
|
188
|
+
console.log(chalk.cyan(`[branch] Empty repo detected — initializing with branch "${sourceBranch}"`));
|
|
189
|
+
const initResult = await initializeSourceBranch(repoPath, sourceBranch);
|
|
190
|
+
if (!initResult.success) {
|
|
191
|
+
return {
|
|
192
|
+
success: false,
|
|
193
|
+
error: `Failed to initialize empty repo: ${initResult.error}`,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
// After init, sourceBranch exists locally — baseRef is already set
|
|
197
|
+
}
|
|
198
|
+
else {
|
|
199
|
+
console.log(chalk.yellow(`[branch] Could not fetch "${sourceBranch}" from remote, using local ref`));
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
// Create the branch
|
|
203
|
+
await execFileAsync("git", ["branch", baseBranch, baseRef], {
|
|
204
|
+
cwd: repoPath,
|
|
205
|
+
});
|
|
206
|
+
console.log(chalk.green(`[branch] Created "${baseBranch}" from "${baseRef}"`));
|
|
207
|
+
// Push workflow branch to remote with tracking
|
|
208
|
+
try {
|
|
209
|
+
await execFileAsync("git", ["push", "-u", "origin", baseBranch], {
|
|
210
|
+
cwd: repoPath,
|
|
211
|
+
});
|
|
212
|
+
console.log(chalk.green(`[branch] Pushed "${baseBranch}" to remote`));
|
|
213
|
+
}
|
|
214
|
+
catch (error_) {
|
|
215
|
+
const msg = error_ instanceof Error ? error_.message : String(error_);
|
|
216
|
+
console.warn(chalk.yellow(`[branch] Failed to push "${baseBranch}" to remote: ${msg} — branch exists locally`));
|
|
217
|
+
// Non-fatal: branch exists locally, can be pushed later
|
|
218
|
+
}
|
|
219
|
+
return { success: true };
|
|
220
|
+
}
|
|
221
|
+
catch (error) {
|
|
222
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
223
|
+
console.error(chalk.red(`[branch] Failed to create branch: ${message}`));
|
|
224
|
+
return { success: false, error: message };
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Normalize a git remote URL for comparison.
|
|
230
|
+
* Handles https, ssh, and .git suffix differences.
|
|
231
|
+
*/
|
|
232
|
+
export function normalizeRepoUrl(url) {
|
|
233
|
+
return url
|
|
234
|
+
.replace(/\.git$/, "")
|
|
235
|
+
.replace(/^git@([^:]+):/, "https://$1/")
|
|
236
|
+
.replace(/\/$/, "")
|
|
237
|
+
.toLowerCase();
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Verify the local repo's origin URL matches the expected URL.
|
|
241
|
+
* Returns null on match, or an error message on mismatch.
|
|
242
|
+
*/
|
|
243
|
+
async function verifyRepoUrl(repoPath, expectedUrl) {
|
|
244
|
+
try {
|
|
245
|
+
const { stdout } = await execFileAsync("git", ["remote", "get-url", "origin"], { cwd: repoPath });
|
|
246
|
+
const localUrl = stdout.trim();
|
|
247
|
+
if (normalizeRepoUrl(localUrl) !== normalizeRepoUrl(expectedUrl)) {
|
|
248
|
+
return `Repo URL mismatch: local="${localUrl}", expected="${expectedUrl}"`;
|
|
249
|
+
}
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
catch {
|
|
253
|
+
return "Could not determine local repo origin URL";
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
// --- PR operations (Story 5c.3) ---
|
|
257
|
+
/**
|
|
258
|
+
* Check if the GitHub CLI (gh) is installed and authenticated.
|
|
259
|
+
*/
|
|
260
|
+
export async function checkGhCliAvailable() {
|
|
261
|
+
try {
|
|
262
|
+
await execFileAsync("gh", ["auth", "status"]);
|
|
263
|
+
return { available: true };
|
|
264
|
+
}
|
|
265
|
+
catch {
|
|
266
|
+
return {
|
|
267
|
+
available: false,
|
|
268
|
+
error: "GitHub CLI (gh) not installed or not authenticated. Run 'gh auth login'.",
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Delete a local branch.
|
|
274
|
+
* Uses per-repo mutex to prevent concurrent branch operations.
|
|
275
|
+
*/
|
|
276
|
+
async function deleteLocalBranch(repoPath, branchName) {
|
|
277
|
+
const mutex = getRepoMutex(repoPath);
|
|
278
|
+
return mutex.runExclusive(async () => {
|
|
279
|
+
try {
|
|
280
|
+
// Check if branch exists
|
|
281
|
+
try {
|
|
282
|
+
await execFileAsync("git", ["rev-parse", "--verify", branchName], {
|
|
283
|
+
cwd: repoPath,
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
catch {
|
|
287
|
+
console.log(chalk.yellow(`[merge] Local branch "${branchName}" does not exist — already cleaned`));
|
|
288
|
+
return { success: true };
|
|
289
|
+
}
|
|
290
|
+
await execFileAsync("git", ["branch", "-D", branchName], {
|
|
291
|
+
cwd: repoPath,
|
|
292
|
+
});
|
|
293
|
+
console.log(chalk.green(`[merge] Deleted local branch "${branchName}"`));
|
|
294
|
+
return { success: true };
|
|
295
|
+
}
|
|
296
|
+
catch (error) {
|
|
297
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
298
|
+
console.error(chalk.red(`[merge] Failed to delete local branch: ${message}`));
|
|
299
|
+
return { success: false, error: message };
|
|
300
|
+
}
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
// --- Worktree operations (Story 5c.2) ---
|
|
304
|
+
/**
|
|
305
|
+
* Create a task branch from a workflow base branch and push it to remote.
|
|
306
|
+
* Idempotent: if the branch already exists, reports success.
|
|
307
|
+
*
|
|
308
|
+
* Steps:
|
|
309
|
+
* 1. Fetch the latest workflow base branch from remote (ensures it's up-to-date)
|
|
310
|
+
* 2. Create the task branch from `origin/<workflowBaseBranch>`
|
|
311
|
+
* 3. Push the task branch to remote with tracking (`-u`)
|
|
312
|
+
*/
|
|
313
|
+
/** Check if a git branch already exists (idempotent guard). Returns true if it exists. */
|
|
314
|
+
async function branchExists(repoPath, branch) {
|
|
315
|
+
try {
|
|
316
|
+
const { stdout } = await execFileAsync("git", ["rev-parse", "--verify", branch], { cwd: repoPath });
|
|
317
|
+
if (stdout.trim()) {
|
|
318
|
+
console.log(chalk.green(`[worktree] Task branch "${branch}" already exists — idempotent success`));
|
|
319
|
+
return true;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
catch {
|
|
323
|
+
// Branch doesn't exist — expected path
|
|
324
|
+
}
|
|
325
|
+
return false;
|
|
326
|
+
}
|
|
327
|
+
/** Push a branch to remote with tracking (non-fatal on failure). */
|
|
328
|
+
async function pushBranchToRemote(repoPath, branch) {
|
|
329
|
+
try {
|
|
330
|
+
await execFileAsync("git", ["push", "-u", "origin", branch], {
|
|
331
|
+
cwd: repoPath,
|
|
332
|
+
});
|
|
333
|
+
console.log(chalk.green(`[worktree] Pushed task branch "${branch}" to remote`));
|
|
334
|
+
}
|
|
335
|
+
catch (error_) {
|
|
336
|
+
const msg = error_ instanceof Error ? error_.message : String(error_);
|
|
337
|
+
console.warn(chalk.yellow(`[worktree] Failed to push task branch "${branch}": ${msg} — agent can push later`));
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
export async function createTaskBranch(repoPath, workflowBaseBranch, taskBranch) {
|
|
341
|
+
const mutex = getRepoMutex(repoPath);
|
|
342
|
+
return mutex.runExclusive(async () => {
|
|
343
|
+
try {
|
|
344
|
+
if (await branchExists(repoPath, taskBranch)) {
|
|
345
|
+
return { success: true };
|
|
346
|
+
}
|
|
347
|
+
// Fetch latest workflow base branch from remote to ensure we branch
|
|
348
|
+
// from the most recent state (other agents may have pushed commits)
|
|
349
|
+
let baseRef = workflowBaseBranch;
|
|
350
|
+
try {
|
|
351
|
+
await execFileAsync("git", ["fetch", "origin", workflowBaseBranch], {
|
|
352
|
+
cwd: repoPath,
|
|
353
|
+
});
|
|
354
|
+
baseRef = `origin/${workflowBaseBranch}`;
|
|
355
|
+
console.log(chalk.blue(`[worktree] Fetched latest "${workflowBaseBranch}" from remote`));
|
|
356
|
+
}
|
|
357
|
+
catch {
|
|
358
|
+
console.log(chalk.yellow(`[worktree] Could not fetch "${workflowBaseBranch}" from remote, using local ref`));
|
|
359
|
+
}
|
|
360
|
+
await execFileAsync("git", ["branch", taskBranch, baseRef], {
|
|
361
|
+
cwd: repoPath,
|
|
362
|
+
});
|
|
363
|
+
console.log(chalk.green(`[worktree] Created task branch "${taskBranch}" from "${baseRef}"`));
|
|
364
|
+
await pushBranchToRemote(repoPath, taskBranch);
|
|
365
|
+
return { success: true };
|
|
366
|
+
}
|
|
367
|
+
catch (error) {
|
|
368
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
369
|
+
console.error(chalk.red(`[worktree] Failed to create task branch: ${message}`));
|
|
370
|
+
return { success: false, error: message };
|
|
371
|
+
}
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* Create a task branch from HEAD (fallback for non-workflow tasks).
|
|
376
|
+
* Idempotent: if the branch already exists, reports success.
|
|
377
|
+
*
|
|
378
|
+
* Used when a task is dispatched without a workflow base branch,
|
|
379
|
+
* providing worktree isolation even for standalone tasks.
|
|
380
|
+
*/
|
|
381
|
+
export async function createTaskBranchFromHead(repoPath, taskBranch) {
|
|
382
|
+
const mutex = getRepoMutex(repoPath);
|
|
383
|
+
return mutex.runExclusive(async () => {
|
|
384
|
+
try {
|
|
385
|
+
if (await branchExists(repoPath, taskBranch)) {
|
|
386
|
+
return { success: true };
|
|
387
|
+
}
|
|
388
|
+
await execFileAsync("git", ["branch", taskBranch, "HEAD"], {
|
|
389
|
+
cwd: repoPath,
|
|
390
|
+
});
|
|
391
|
+
console.log(chalk.green(`[worktree] Created task branch "${taskBranch}" from HEAD (no workflow branch)`));
|
|
392
|
+
await pushBranchToRemote(repoPath, taskBranch);
|
|
393
|
+
return { success: true };
|
|
394
|
+
}
|
|
395
|
+
catch (error) {
|
|
396
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
397
|
+
console.error(chalk.red(`[worktree] Failed to create task branch from HEAD: ${message}`));
|
|
398
|
+
return { success: false, error: message };
|
|
399
|
+
}
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* Ensure all changes in a repository are committed and pushed.
|
|
404
|
+
* Best-effort safety net: auto-commits dirty working tree and pushes unpushed commits.
|
|
405
|
+
* Uses per-repo mutex to prevent race conditions between concurrent callers.
|
|
406
|
+
*/
|
|
407
|
+
export async function ensureChangesCommittedAndPushed(repoPath, commitMessage) {
|
|
408
|
+
const mutex = getRepoMutex(repoPath);
|
|
409
|
+
return mutex.runExclusive(async () => {
|
|
410
|
+
let committed = false;
|
|
411
|
+
let pushed = false;
|
|
412
|
+
try {
|
|
413
|
+
// Verify path exists and is a git directory
|
|
414
|
+
if (!existsSync(repoPath)) {
|
|
415
|
+
return {
|
|
416
|
+
committed,
|
|
417
|
+
pushed,
|
|
418
|
+
error: `Path does not exist: ${repoPath}`,
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
// 1. Check for uncommitted changes
|
|
422
|
+
const { stdout: status } = await execFileAsync("git", ["status", "--porcelain"], { cwd: repoPath });
|
|
423
|
+
if (status.trim()) {
|
|
424
|
+
// Dirty working tree — auto-commit
|
|
425
|
+
await execFileAsync("git", ["add", "-A"], { cwd: repoPath });
|
|
426
|
+
await execFileAsync("git", ["commit", "-m", commitMessage], {
|
|
427
|
+
cwd: repoPath,
|
|
428
|
+
});
|
|
429
|
+
committed = true;
|
|
430
|
+
console.log(chalk.green(`[guard] Auto-committed changes in ${repoPath}`));
|
|
431
|
+
}
|
|
432
|
+
// 2. Push any unpushed commits
|
|
433
|
+
// First try with upstream tracking, fall back to explicit push if no upstream
|
|
434
|
+
try {
|
|
435
|
+
const { stdout: unpushedLog } = await execFileAsync("git", ["log", "@{upstream}..HEAD", "--oneline"], { cwd: repoPath });
|
|
436
|
+
if (unpushedLog.trim()) {
|
|
437
|
+
await execFileAsync("git", ["push"], { cwd: repoPath });
|
|
438
|
+
pushed = true;
|
|
439
|
+
console.log(chalk.green(`[guard] Auto-pushed commits from ${repoPath}`));
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
catch {
|
|
443
|
+
// No upstream configured — detect current branch and push with --set-upstream
|
|
444
|
+
try {
|
|
445
|
+
const { stdout: branchOutput } = await execFileAsync("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd: repoPath });
|
|
446
|
+
const currentBranch = branchOutput.trim();
|
|
447
|
+
if (currentBranch && currentBranch !== "HEAD") {
|
|
448
|
+
await execFileAsync("git", ["push", "--set-upstream", "origin", currentBranch], { cwd: repoPath });
|
|
449
|
+
pushed = true;
|
|
450
|
+
console.log(chalk.green(`[guard] Auto-pushed and set upstream for "${currentBranch}" from ${repoPath}`));
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
catch (error_) {
|
|
454
|
+
const msg = error_ instanceof Error ? error_.message : String(error_);
|
|
455
|
+
console.warn(chalk.yellow(`[guard] Failed to push from ${repoPath}: ${msg}`));
|
|
456
|
+
return { committed, pushed, error: `Push failed: ${msg}` };
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
return { committed, pushed };
|
|
460
|
+
}
|
|
461
|
+
catch (error) {
|
|
462
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
463
|
+
console.error(chalk.red(`[guard] Failed to ensure changes committed/pushed: ${message}`));
|
|
464
|
+
return { committed, pushed, error: message };
|
|
465
|
+
}
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
/**
|
|
469
|
+
* Ensure a workflow branch exists locally.
|
|
470
|
+
* Handles 3 scenarios (in order), using per-repo mutex:
|
|
471
|
+
* 1. Branch exists locally → pull latest from remote (fast-forward)
|
|
472
|
+
* 2. Branch only on remote → fetch + create local tracking branch
|
|
473
|
+
* 3. Branch doesn't exist anywhere → create from sourceBranch
|
|
474
|
+
*/
|
|
475
|
+
export async function ensureWorkflowBranch(repoPath, baseBranch, sourceBranch) {
|
|
476
|
+
const mutex = getRepoMutex(repoPath);
|
|
477
|
+
return mutex.runExclusive(async () => {
|
|
478
|
+
try {
|
|
479
|
+
// 1. Check if branch exists locally
|
|
480
|
+
try {
|
|
481
|
+
const { stdout } = await execFileAsync("git", ["rev-parse", "--verify", baseBranch], { cwd: repoPath });
|
|
482
|
+
if (stdout.trim()) {
|
|
483
|
+
console.log(chalk.green(`[worktree] Workflow branch "${baseBranch}" already exists locally — idempotent success`));
|
|
484
|
+
// Branch exists locally — update to latest remote state
|
|
485
|
+
// Uses `git branch -f` instead of `git merge` to avoid affecting the
|
|
486
|
+
// currently checked-out branch in the main repo
|
|
487
|
+
try {
|
|
488
|
+
await execFileAsync("git", ["fetch", "origin", baseBranch], {
|
|
489
|
+
cwd: repoPath,
|
|
490
|
+
});
|
|
491
|
+
await execFileAsync("git", ["branch", "-f", baseBranch, `origin/${baseBranch}`], { cwd: repoPath });
|
|
492
|
+
console.log(chalk.blue(`[branch] Updated "${baseBranch}" to latest remote state`));
|
|
493
|
+
}
|
|
494
|
+
catch {
|
|
495
|
+
// Remote may not have the branch yet (first workflow step) — that's OK
|
|
496
|
+
console.log(chalk.yellow(`[branch] Could not update "${baseBranch}" from remote, using local state`));
|
|
497
|
+
}
|
|
498
|
+
return { success: true };
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
catch {
|
|
502
|
+
// Branch doesn't exist locally — continue
|
|
503
|
+
}
|
|
504
|
+
// 2. Check if branch exists on remote → fetch + create local branch
|
|
505
|
+
try {
|
|
506
|
+
await execFileAsync("git", ["fetch", "origin", baseBranch], {
|
|
507
|
+
cwd: repoPath,
|
|
508
|
+
});
|
|
509
|
+
// Fetch succeeded — create local branch from remote
|
|
510
|
+
await execFileAsync("git", ["branch", baseBranch, `origin/${baseBranch}`], { cwd: repoPath });
|
|
511
|
+
console.log(chalk.green(`[worktree] Fetched and created local branch "${baseBranch}" from remote`));
|
|
512
|
+
return { success: true };
|
|
513
|
+
}
|
|
514
|
+
catch {
|
|
515
|
+
// Branch not on remote either — continue to create from source
|
|
516
|
+
}
|
|
517
|
+
// 3. Branch doesn't exist anywhere — create from sourceBranch
|
|
518
|
+
// If repo is empty (no commits), initialize with the source branch first
|
|
519
|
+
if (await isRepoEmpty(repoPath)) {
|
|
520
|
+
console.log(chalk.cyan(`[worktree] Empty repo detected — initializing with branch "${sourceBranch}"`));
|
|
521
|
+
const initResult = await initializeSourceBranch(repoPath, sourceBranch);
|
|
522
|
+
if (!initResult.success) {
|
|
523
|
+
return {
|
|
524
|
+
success: false,
|
|
525
|
+
error: `Failed to initialize empty repo: ${initResult.error}`,
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
await execFileAsync("git", ["branch", baseBranch, sourceBranch], {
|
|
530
|
+
cwd: repoPath,
|
|
531
|
+
});
|
|
532
|
+
console.log(chalk.green(`[worktree] Created workflow branch "${baseBranch}" from "${sourceBranch}"`));
|
|
533
|
+
return { success: true };
|
|
534
|
+
}
|
|
535
|
+
catch (error) {
|
|
536
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
537
|
+
console.error(chalk.red(`[worktree] Failed to ensure workflow branch: ${message}`));
|
|
538
|
+
return { success: false, error: message };
|
|
539
|
+
}
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
/**
|
|
543
|
+
* Register branch-related Socket.io event handlers on a CLI socket.
|
|
544
|
+
* Handles branch listing and branch creation requests from Cloud.
|
|
545
|
+
*/
|
|
546
|
+
export function registerBranchHandlers(socket, orgId) {
|
|
547
|
+
socket.on("workflow:branch:list:request", async (payload) => {
|
|
548
|
+
try {
|
|
549
|
+
const repoPath = await resolveRepoPath(getProjectSlugOrDefault(payload), payload.repoUrl);
|
|
550
|
+
// Verify repo URL matches before listing
|
|
551
|
+
const mismatch = await verifyRepoUrl(repoPath, payload.repoUrl);
|
|
552
|
+
if (mismatch) {
|
|
553
|
+
console.warn(chalk.yellow(`[branch] ${mismatch} — skipping list`));
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
const result = await listBranches(repoPath);
|
|
557
|
+
socket.emit("workflow:branch:list:response", {
|
|
558
|
+
orgId,
|
|
559
|
+
projectId: payload.projectId,
|
|
560
|
+
branches: result.branches,
|
|
561
|
+
defaultBranch: result.defaultBranch,
|
|
562
|
+
timestamp: new Date().toISOString(),
|
|
563
|
+
});
|
|
564
|
+
console.log(chalk.green(`[branch] Listed ${result.branches.length} branches (default: ${result.defaultBranch})`));
|
|
565
|
+
}
|
|
566
|
+
catch (error) {
|
|
567
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
568
|
+
console.error(chalk.red(`[branch] Failed to list branches: ${message}`));
|
|
569
|
+
// Send empty response so UI can fall back to text input
|
|
570
|
+
socket.emit("workflow:branch:list:response", {
|
|
571
|
+
orgId,
|
|
572
|
+
projectId: payload.projectId,
|
|
573
|
+
branches: [],
|
|
574
|
+
defaultBranch: "main",
|
|
575
|
+
timestamp: new Date().toISOString(),
|
|
576
|
+
});
|
|
577
|
+
}
|
|
578
|
+
});
|
|
579
|
+
socket.on("workflow:branch:create", async (payload) => {
|
|
580
|
+
try {
|
|
581
|
+
const repoPath = await resolveRepoPath(getProjectSlugOrDefault(payload), payload.repoUrl);
|
|
582
|
+
// Verify repo URL matches before creating branch
|
|
583
|
+
const mismatch = await verifyRepoUrl(repoPath, payload.repoUrl);
|
|
584
|
+
if (mismatch) {
|
|
585
|
+
console.warn(chalk.yellow(`[branch] ${mismatch}`));
|
|
586
|
+
socket.emit("workflow:branch:created", {
|
|
587
|
+
orgId,
|
|
588
|
+
workflowId: payload.workflowId,
|
|
589
|
+
baseBranch: payload.baseBranch,
|
|
590
|
+
success: false,
|
|
591
|
+
error: mismatch,
|
|
592
|
+
timestamp: new Date().toISOString(),
|
|
593
|
+
});
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
596
|
+
const result = await createWorkflowBranch(repoPath, payload.sourceBranch, payload.baseBranch);
|
|
597
|
+
socket.emit("workflow:branch:created", {
|
|
598
|
+
orgId,
|
|
599
|
+
workflowId: payload.workflowId,
|
|
600
|
+
baseBranch: payload.baseBranch,
|
|
601
|
+
success: result.success,
|
|
602
|
+
error: result.error,
|
|
603
|
+
timestamp: new Date().toISOString(),
|
|
604
|
+
});
|
|
605
|
+
}
|
|
606
|
+
catch (error) {
|
|
607
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
608
|
+
console.error(chalk.red(`[branch] Unexpected error creating branch: ${message}`));
|
|
609
|
+
socket.emit("workflow:branch:created", {
|
|
610
|
+
orgId,
|
|
611
|
+
workflowId: payload.workflowId,
|
|
612
|
+
baseBranch: payload.baseBranch,
|
|
613
|
+
success: false,
|
|
614
|
+
error: message,
|
|
615
|
+
timestamp: new Date().toISOString(),
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
});
|
|
619
|
+
}
|
|
620
|
+
//# sourceMappingURL=branch-manager.js.map
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Clone a repo and checkout a specific branch, or fetch + checkout if already cloned.
|
|
3
|
+
* Target directory: ~/.forgeai/open/<projectSlug>/<branchSlug>/
|
|
4
|
+
*/
|
|
5
|
+
export declare function cloneAndCheckout(repoUrl: string, branch: string, targetDir: string): Promise<void>;
|
|
6
|
+
//# sourceMappingURL=clone-and-checkout.d.ts.map
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { mkdir } from "node:fs/promises";
|
|
3
|
+
import { join, dirname } from "node:path";
|
|
4
|
+
import { execFile } from "node:child_process";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
import chalk from "chalk";
|
|
7
|
+
const execFileAsync = promisify(execFile);
|
|
8
|
+
/**
|
|
9
|
+
* Clone a repo and checkout a specific branch, or fetch + checkout if already cloned.
|
|
10
|
+
* Target directory: ~/.forgeai/open/<projectSlug>/<branchSlug>/
|
|
11
|
+
*/
|
|
12
|
+
export async function cloneAndCheckout(repoUrl, branch, targetDir) {
|
|
13
|
+
const gitDir = join(targetDir, ".git");
|
|
14
|
+
if (existsSync(gitDir)) {
|
|
15
|
+
// Already cloned — fetch and checkout
|
|
16
|
+
console.log(chalk.dim(`[open] Fetching latest changes in ${targetDir}`));
|
|
17
|
+
await execFileAsync("git", ["fetch", "--all"], { cwd: targetDir });
|
|
18
|
+
await execFileAsync("git", ["checkout", branch], { cwd: targetDir }).catch(async () => {
|
|
19
|
+
// Branch might be remote-only
|
|
20
|
+
await execFileAsync("git", ["checkout", "-b", branch, `origin/${branch}`], { cwd: targetDir });
|
|
21
|
+
});
|
|
22
|
+
await execFileAsync("git", ["pull", "--ff-only"], { cwd: targetDir }).catch(() => {
|
|
23
|
+
// Pull may fail if detached or diverged — non-fatal
|
|
24
|
+
});
|
|
25
|
+
console.log(chalk.green(`[open] Checked out ${branch}`));
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
// Fresh clone
|
|
29
|
+
await mkdir(dirname(targetDir), { recursive: true });
|
|
30
|
+
console.log(chalk.dim(`[open] Cloning ${repoUrl} → ${targetDir}`));
|
|
31
|
+
await execFileAsync("git", [
|
|
32
|
+
"clone",
|
|
33
|
+
"--branch",
|
|
34
|
+
branch,
|
|
35
|
+
repoUrl,
|
|
36
|
+
targetDir,
|
|
37
|
+
]);
|
|
38
|
+
console.log(chalk.green(`[open] Cloned and checked out ${branch}`));
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
//# sourceMappingURL=clone-and-checkout.js.map
|