xcode-build-queue 0.1.0
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/README.md +266 -0
- package/dist/backends/mcp.d.ts +5 -0
- package/dist/backends/mcp.js +224 -0
- package/dist/backends/xcodebuild.d.ts +5 -0
- package/dist/backends/xcodebuild.js +83 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +244 -0
- package/dist/config.d.ts +15 -0
- package/dist/config.js +114 -0
- package/dist/daemon.d.ts +8 -0
- package/dist/daemon.js +186 -0
- package/dist/enqueue.d.ts +14 -0
- package/dist/enqueue.js +133 -0
- package/dist/executor.d.ts +5 -0
- package/dist/executor.js +134 -0
- package/dist/setup-claude.d.ts +10 -0
- package/dist/setup-claude.js +100 -0
- package/dist/snapshot.d.ts +23 -0
- package/dist/snapshot.js +99 -0
- package/dist/types.d.ts +33 -0
- package/dist/types.js +13 -0
- package/dist/utils.d.ts +41 -0
- package/dist/utils.js +118 -0
- package/dist/worktree.d.ts +17 -0
- package/dist/worktree.js +239 -0
- package/package.json +43 -0
package/dist/enqueue.js
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.enqueueAndWait = enqueueAndWait;
|
|
4
|
+
const node_fs_1 = require("node:fs");
|
|
5
|
+
const node_path_1 = require("node:path");
|
|
6
|
+
const config_js_1 = require("./config.js");
|
|
7
|
+
const snapshot_js_1 = require("./snapshot.js");
|
|
8
|
+
const utils_js_1 = require("./utils.js");
|
|
9
|
+
/**
|
|
10
|
+
* Enqueue a job, wait for result, and return it.
|
|
11
|
+
*/
|
|
12
|
+
async function enqueueAndWait(opts) {
|
|
13
|
+
(0, utils_js_1.ensureDirs)();
|
|
14
|
+
const config = (0, config_js_1.loadConfig)();
|
|
15
|
+
// Ensure daemon is running
|
|
16
|
+
ensureDaemonRunning();
|
|
17
|
+
// Determine build strategy
|
|
18
|
+
const jobId = (0, utils_js_1.generateJobId)();
|
|
19
|
+
let branch = opts.branch;
|
|
20
|
+
let snapshotSha;
|
|
21
|
+
if (!branch) {
|
|
22
|
+
if ((0, snapshot_js_1.isInWorktree)()) {
|
|
23
|
+
snapshotSha = (0, snapshot_js_1.createSnapshot)();
|
|
24
|
+
utils_js_1.log.ok(`Snapshot: ${snapshotSha.slice(0, 8)}`);
|
|
25
|
+
}
|
|
26
|
+
else {
|
|
27
|
+
utils_js_1.log.error("Not in a worktree. Specify --branch or run from a worktree.");
|
|
28
|
+
process.exit(1);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
// Create job
|
|
32
|
+
const job = {
|
|
33
|
+
id: jobId,
|
|
34
|
+
action: opts.action,
|
|
35
|
+
branch,
|
|
36
|
+
snapshot_sha: snapshotSha,
|
|
37
|
+
scheme: opts.scheme || config.default_scheme,
|
|
38
|
+
test_plan: opts.action === "test" ? (opts.testPlan || config.default_test_plan || undefined) : undefined,
|
|
39
|
+
destination: opts.destination || config.default_destination,
|
|
40
|
+
backend: opts.backend || config.backend,
|
|
41
|
+
submitted_at: new Date().toISOString(),
|
|
42
|
+
submitted_by: detectWorktreeName(),
|
|
43
|
+
};
|
|
44
|
+
// Write to queue
|
|
45
|
+
const jobFile = (0, node_path_1.join)(utils_js_1.BQ_QUEUE_DIR, `${job.id}.json`);
|
|
46
|
+
(0, node_fs_1.writeFileSync)(jobFile, JSON.stringify(job, null, 2) + "\n");
|
|
47
|
+
// Show queue position
|
|
48
|
+
const queueSize = (0, node_fs_1.readdirSync)(utils_js_1.BQ_QUEUE_DIR).filter(f => f.endsWith(".json")).length;
|
|
49
|
+
if (queueSize > 1) {
|
|
50
|
+
utils_js_1.log.status(`Queued (position: ${queueSize})`);
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
utils_js_1.log.status("Queued (next up)");
|
|
54
|
+
}
|
|
55
|
+
// Wait for result
|
|
56
|
+
const timeout = opts.timeout || 1800; // 30 min
|
|
57
|
+
return waitForResult(job.id, timeout);
|
|
58
|
+
}
|
|
59
|
+
function ensureDaemonRunning() {
|
|
60
|
+
if ((0, node_fs_1.existsSync)(utils_js_1.BQ_PID_FILE)) {
|
|
61
|
+
const pid = parseInt((0, node_fs_1.readFileSync)(utils_js_1.BQ_PID_FILE, "utf-8").trim());
|
|
62
|
+
if ((0, utils_js_1.isProcessAlive)(pid))
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
// Auto-start daemon in background
|
|
66
|
+
utils_js_1.log.info("Starting daemon...");
|
|
67
|
+
const { spawn } = require("node:child_process");
|
|
68
|
+
const daemonProcess = spawn(process.argv[0], // node
|
|
69
|
+
[process.argv[1], "daemon", "start"], {
|
|
70
|
+
detached: true,
|
|
71
|
+
stdio: "ignore",
|
|
72
|
+
});
|
|
73
|
+
daemonProcess.unref();
|
|
74
|
+
// Wait briefly for daemon to start
|
|
75
|
+
const start = Date.now();
|
|
76
|
+
while (Date.now() - start < 3000) {
|
|
77
|
+
if ((0, node_fs_1.existsSync)(utils_js_1.BQ_PID_FILE)) {
|
|
78
|
+
const pid = parseInt((0, node_fs_1.readFileSync)(utils_js_1.BQ_PID_FILE, "utf-8").trim());
|
|
79
|
+
if ((0, utils_js_1.isProcessAlive)(pid)) {
|
|
80
|
+
utils_js_1.log.ok("Daemon started");
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
require("node:child_process").execSync("sleep 0.2");
|
|
85
|
+
}
|
|
86
|
+
utils_js_1.log.warn("Daemon may not have started — will proceed anyway");
|
|
87
|
+
}
|
|
88
|
+
async function waitForResult(jobId, timeoutSec) {
|
|
89
|
+
const resultFile = (0, node_path_1.join)(utils_js_1.BQ_RESULTS_DIR, `${jobId}.json`);
|
|
90
|
+
const startTime = Date.now();
|
|
91
|
+
let lastStatus = "";
|
|
92
|
+
while (true) {
|
|
93
|
+
const elapsed = Math.round((Date.now() - startTime) / 1000);
|
|
94
|
+
if (elapsed > timeoutSec) {
|
|
95
|
+
utils_js_1.log.error(`Timed out after ${timeoutSec}s`);
|
|
96
|
+
return {
|
|
97
|
+
id: jobId,
|
|
98
|
+
status: "error",
|
|
99
|
+
duration_seconds: elapsed,
|
|
100
|
+
summary: `Timed out after ${timeoutSec}s`,
|
|
101
|
+
failures: [],
|
|
102
|
+
build_errors: ["Job timed out"],
|
|
103
|
+
warnings: [],
|
|
104
|
+
log_path: "",
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
if ((0, node_fs_1.existsSync)(resultFile)) {
|
|
108
|
+
const result = JSON.parse((0, node_fs_1.readFileSync)(resultFile, "utf-8"));
|
|
109
|
+
return result;
|
|
110
|
+
}
|
|
111
|
+
// Periodic status update (every 10s)
|
|
112
|
+
if (elapsed > 0 && elapsed % 10 === 0) {
|
|
113
|
+
const status = `Waiting... (${elapsed}s)`;
|
|
114
|
+
if (status !== lastStatus) {
|
|
115
|
+
utils_js_1.log.status(status);
|
|
116
|
+
lastStatus = status;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
function detectWorktreeName() {
|
|
123
|
+
try {
|
|
124
|
+
const cwd = process.cwd();
|
|
125
|
+
const match = cwd.match(/worktrees?\/([^/]+)/);
|
|
126
|
+
if (match)
|
|
127
|
+
return match[1];
|
|
128
|
+
return cwd.split("/").pop() || "unknown";
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
return "unknown";
|
|
132
|
+
}
|
|
133
|
+
}
|
package/dist/executor.js
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.executeJob = executeJob;
|
|
4
|
+
const config_js_1 = require("./config.js");
|
|
5
|
+
const snapshot_js_1 = require("./snapshot.js");
|
|
6
|
+
const utils_js_1 = require("./utils.js");
|
|
7
|
+
const mcp_js_1 = require("./backends/mcp.js");
|
|
8
|
+
const xcodebuild_js_1 = require("./backends/xcodebuild.js");
|
|
9
|
+
/**
|
|
10
|
+
* Execute a single job: apply patch or checkout branch, build/test, return results.
|
|
11
|
+
*/
|
|
12
|
+
async function executeJob(job) {
|
|
13
|
+
const config = (0, config_js_1.loadConfig)();
|
|
14
|
+
const repoPath = (0, utils_js_1.expandPath)(config.main_repo);
|
|
15
|
+
const workspace = config.workspace;
|
|
16
|
+
// 1. Stash any uncommitted changes in main repo
|
|
17
|
+
const stashed = stashIfDirty(repoPath);
|
|
18
|
+
try {
|
|
19
|
+
if (job.snapshot_sha) {
|
|
20
|
+
// Snapshot mode: detached HEAD checkout
|
|
21
|
+
(0, snapshot_js_1.applySnapshot)(repoPath, job.snapshot_sha);
|
|
22
|
+
}
|
|
23
|
+
else if (job.branch) {
|
|
24
|
+
// Branch mode: fetch and checkout
|
|
25
|
+
utils_js_1.log.info("Fetching latest changes...");
|
|
26
|
+
(0, utils_js_1.run)("git fetch --all --prune", { cwd: repoPath, quiet: true });
|
|
27
|
+
utils_js_1.log.info(`Checking out branch: ${job.branch}`);
|
|
28
|
+
checkoutBranch(repoPath, job.branch);
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
throw new Error("Job has neither snapshot_sha nor branch");
|
|
32
|
+
}
|
|
33
|
+
// Restore file timestamps for incremental builds
|
|
34
|
+
if (config.git_restore_mtime) {
|
|
35
|
+
restoreMtime(repoPath);
|
|
36
|
+
}
|
|
37
|
+
// Run build/test via selected backend
|
|
38
|
+
const backend = job.backend || config.backend;
|
|
39
|
+
utils_js_1.log.info(`Backend: ${backend}`);
|
|
40
|
+
if (backend === "mcp") {
|
|
41
|
+
try {
|
|
42
|
+
return await (0, mcp_js_1.executeWithMCP)(job, repoPath, workspace);
|
|
43
|
+
}
|
|
44
|
+
catch (err) {
|
|
45
|
+
if (config.xcodebuild_fallback) {
|
|
46
|
+
utils_js_1.log.warn(`MCP failed, falling back to xcodebuild: ${err}`);
|
|
47
|
+
return await (0, xcodebuild_js_1.executeWithXcodebuild)(job, repoPath, workspace);
|
|
48
|
+
}
|
|
49
|
+
throw err;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
return await (0, xcodebuild_js_1.executeWithXcodebuild)(job, repoPath, workspace);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
catch (err) {
|
|
57
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
58
|
+
utils_js_1.log.error(`Job failed: ${message}`);
|
|
59
|
+
return {
|
|
60
|
+
id: job.id,
|
|
61
|
+
status: "error",
|
|
62
|
+
duration_seconds: 0,
|
|
63
|
+
summary: message,
|
|
64
|
+
failures: [],
|
|
65
|
+
build_errors: [message],
|
|
66
|
+
warnings: [],
|
|
67
|
+
log_path: "",
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
finally {
|
|
71
|
+
// Return to default branch after snapshot checkout
|
|
72
|
+
if (job.snapshot_sha) {
|
|
73
|
+
(0, snapshot_js_1.cleanSnapshot)(repoPath);
|
|
74
|
+
}
|
|
75
|
+
// Restore stashed changes
|
|
76
|
+
if (stashed) {
|
|
77
|
+
try {
|
|
78
|
+
(0, utils_js_1.run)("git stash pop", { cwd: repoPath, quiet: true });
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
utils_js_1.log.warn("Could not restore stashed changes in main repo");
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
function stashIfDirty(repoPath) {
|
|
87
|
+
const status = (0, utils_js_1.run)("git status --porcelain", { cwd: repoPath, quiet: true });
|
|
88
|
+
if (status.length > 0) {
|
|
89
|
+
utils_js_1.log.warn("Main repo has uncommitted changes — stashing");
|
|
90
|
+
(0, utils_js_1.run)("git stash push -m 'xbq: auto-stash before build'", { cwd: repoPath, quiet: true });
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
function checkoutBranch(repoPath, branch) {
|
|
96
|
+
// Try local branch first
|
|
97
|
+
try {
|
|
98
|
+
(0, utils_js_1.run)(`git checkout ${branch}`, { cwd: repoPath, quiet: true });
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
// Branch might not exist locally
|
|
103
|
+
}
|
|
104
|
+
// Try tracking remote branch
|
|
105
|
+
try {
|
|
106
|
+
(0, utils_js_1.run)(`git checkout -b ${branch} origin/${branch}`, { cwd: repoPath, quiet: true });
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
// Already exists or other error
|
|
111
|
+
}
|
|
112
|
+
// Force update local branch to match remote
|
|
113
|
+
try {
|
|
114
|
+
(0, utils_js_1.run)(`git checkout ${branch}`, { cwd: repoPath, quiet: true });
|
|
115
|
+
(0, utils_js_1.run)(`git reset --hard origin/${branch}`, { cwd: repoPath, quiet: true });
|
|
116
|
+
}
|
|
117
|
+
catch (err) {
|
|
118
|
+
throw new Error(`Failed to checkout branch '${branch}': ${err}`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Restore file modification times from git history.
|
|
123
|
+
* This helps Xcode's incremental build system avoid unnecessary recompilation.
|
|
124
|
+
*/
|
|
125
|
+
function restoreMtime(repoPath) {
|
|
126
|
+
try {
|
|
127
|
+
(0, utils_js_1.run)("which git-restore-mtime", { quiet: true });
|
|
128
|
+
utils_js_1.log.dim("Restoring file timestamps...");
|
|
129
|
+
(0, utils_js_1.run)("git-restore-mtime --skip-missing", { cwd: repoPath, quiet: true });
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
// git-restore-mtime not installed, skip silently
|
|
133
|
+
}
|
|
134
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inject xbq instructions into CLAUDE.md at a given path.
|
|
3
|
+
* If CLAUDE.md exists, appends the snippet (or updates if already present).
|
|
4
|
+
* If CLAUDE.md doesn't exist, creates it.
|
|
5
|
+
*/
|
|
6
|
+
export declare function setupClaude(targetDir?: string): void;
|
|
7
|
+
/**
|
|
8
|
+
* Remove xbq instructions from CLAUDE.md.
|
|
9
|
+
*/
|
|
10
|
+
export declare function removeClaude(targetDir?: string): void;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.setupClaude = setupClaude;
|
|
4
|
+
exports.removeClaude = removeClaude;
|
|
5
|
+
const node_fs_1 = require("node:fs");
|
|
6
|
+
const node_path_1 = require("node:path");
|
|
7
|
+
const utils_js_1 = require("./utils.js");
|
|
8
|
+
const MARKER_START = "<!-- xbq:start -->";
|
|
9
|
+
const MARKER_END = "<!-- xbq:end -->";
|
|
10
|
+
const SNIPPET = `${MARKER_START}
|
|
11
|
+
## Build Queue (xbq)
|
|
12
|
+
|
|
13
|
+
This project uses \`xbq\` (Xcode Build Queue) for all build and test operations. You are working in a **lightweight worktree** — do NOT build or run tests directly here.
|
|
14
|
+
|
|
15
|
+
### Rules
|
|
16
|
+
|
|
17
|
+
1. **NEVER run \`xcodebuild\` directly** in this worktree. Always use \`xbq\`.
|
|
18
|
+
2. **NEVER run Xcode MCP build/test tools** directly. Route through \`xbq\`.
|
|
19
|
+
|
|
20
|
+
### Workflow
|
|
21
|
+
|
|
22
|
+
\`\`\`bash
|
|
23
|
+
# 1. Build (automatically captures your changes via diff)
|
|
24
|
+
xbq build
|
|
25
|
+
|
|
26
|
+
# 2. Test
|
|
27
|
+
xbq test
|
|
28
|
+
|
|
29
|
+
# 3. Test with specific plan
|
|
30
|
+
xbq test --test-plan All
|
|
31
|
+
|
|
32
|
+
# 4. Build with specific destination
|
|
33
|
+
xbq build --destination "platform=iOS Simulator,name=iPhone 16,OS=18.0"
|
|
34
|
+
|
|
35
|
+
# 5. Check queue status
|
|
36
|
+
xbq status
|
|
37
|
+
\`\`\`
|
|
38
|
+
|
|
39
|
+
### Important
|
|
40
|
+
|
|
41
|
+
- Changes are captured automatically — no need to commit before building
|
|
42
|
+
- \`xbq\` is a serial queue — your job may wait if another session is building
|
|
43
|
+
- Results include build errors and test failures in structured output
|
|
44
|
+
- For full logs: \`xbq logs <job-id>\`
|
|
45
|
+
- Exit code is 0 on success, 1 on failure — use it to determine next steps
|
|
46
|
+
${MARKER_END}`;
|
|
47
|
+
/**
|
|
48
|
+
* Inject xbq instructions into CLAUDE.md at a given path.
|
|
49
|
+
* If CLAUDE.md exists, appends the snippet (or updates if already present).
|
|
50
|
+
* If CLAUDE.md doesn't exist, creates it.
|
|
51
|
+
*/
|
|
52
|
+
function setupClaude(targetDir) {
|
|
53
|
+
const dir = targetDir || process.cwd();
|
|
54
|
+
const claudeMd = (0, node_path_1.join)(dir, "CLAUDE.md");
|
|
55
|
+
if (!(0, node_fs_1.existsSync)(dir)) {
|
|
56
|
+
(0, node_fs_1.mkdirSync)(dir, { recursive: true });
|
|
57
|
+
}
|
|
58
|
+
if ((0, node_fs_1.existsSync)(claudeMd)) {
|
|
59
|
+
const content = (0, node_fs_1.readFileSync)(claudeMd, "utf-8");
|
|
60
|
+
if (content.includes(MARKER_START)) {
|
|
61
|
+
// Update existing snippet
|
|
62
|
+
const regex = new RegExp(`${escapeRegex(MARKER_START)}[\\s\\S]*?${escapeRegex(MARKER_END)}`, "g");
|
|
63
|
+
const updated = content.replace(regex, SNIPPET);
|
|
64
|
+
(0, node_fs_1.writeFileSync)(claudeMd, updated);
|
|
65
|
+
utils_js_1.log.ok(`Updated xbq section in ${claudeMd}`);
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
// Append
|
|
69
|
+
(0, node_fs_1.appendFileSync)(claudeMd, "\n\n" + SNIPPET + "\n");
|
|
70
|
+
utils_js_1.log.ok(`Added xbq section to ${claudeMd}`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
(0, node_fs_1.writeFileSync)(claudeMd, SNIPPET + "\n");
|
|
75
|
+
utils_js_1.log.ok(`Created ${claudeMd} with xbq instructions`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Remove xbq instructions from CLAUDE.md.
|
|
80
|
+
*/
|
|
81
|
+
function removeClaude(targetDir) {
|
|
82
|
+
const dir = targetDir || process.cwd();
|
|
83
|
+
const claudeMd = (0, node_path_1.join)(dir, "CLAUDE.md");
|
|
84
|
+
if (!(0, node_fs_1.existsSync)(claudeMd)) {
|
|
85
|
+
utils_js_1.log.warn("No CLAUDE.md found");
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
const content = (0, node_fs_1.readFileSync)(claudeMd, "utf-8");
|
|
89
|
+
if (!content.includes(MARKER_START)) {
|
|
90
|
+
utils_js_1.log.warn("No xbq section found in CLAUDE.md");
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
const regex = new RegExp(`\\n*${escapeRegex(MARKER_START)}[\\s\\S]*?${escapeRegex(MARKER_END)}\\n*`, "g");
|
|
94
|
+
const updated = content.replace(regex, "\n");
|
|
95
|
+
(0, node_fs_1.writeFileSync)(claudeMd, updated.trim() + "\n");
|
|
96
|
+
utils_js_1.log.ok(`Removed xbq section from ${claudeMd}`);
|
|
97
|
+
}
|
|
98
|
+
function escapeRegex(s) {
|
|
99
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
100
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detect the default branch (master, main, develop) for a repo.
|
|
3
|
+
*/
|
|
4
|
+
export declare function getDefaultBranch(repoPath: string): string;
|
|
5
|
+
/**
|
|
6
|
+
* Check if the current directory is inside a git worktree (not the main repo).
|
|
7
|
+
*/
|
|
8
|
+
export declare function isInWorktree(): boolean;
|
|
9
|
+
/**
|
|
10
|
+
* Create a snapshot commit of the current worktree state (committed + uncommitted
|
|
11
|
+
* + untracked) without modifying any refs. Returns a SHA that the main repo can
|
|
12
|
+
* checkout via detached HEAD (shared object store across worktrees).
|
|
13
|
+
*/
|
|
14
|
+
export declare function createSnapshot(): string;
|
|
15
|
+
/**
|
|
16
|
+
* Apply a snapshot by checking out the SHA as a detached HEAD in the main repo.
|
|
17
|
+
* Detached HEAD bypasses the worktree branch lock.
|
|
18
|
+
*/
|
|
19
|
+
export declare function applySnapshot(repoPath: string, sha: string): void;
|
|
20
|
+
/**
|
|
21
|
+
* Clean up the main repo by returning to the default branch.
|
|
22
|
+
*/
|
|
23
|
+
export declare function cleanSnapshot(repoPath: string): void;
|
package/dist/snapshot.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.getDefaultBranch = getDefaultBranch;
|
|
4
|
+
exports.isInWorktree = isInWorktree;
|
|
5
|
+
exports.createSnapshot = createSnapshot;
|
|
6
|
+
exports.applySnapshot = applySnapshot;
|
|
7
|
+
exports.cleanSnapshot = cleanSnapshot;
|
|
8
|
+
const utils_js_1 = require("./utils.js");
|
|
9
|
+
/**
|
|
10
|
+
* Detect the default branch (master, main, develop) for a repo.
|
|
11
|
+
*/
|
|
12
|
+
function getDefaultBranch(repoPath) {
|
|
13
|
+
try {
|
|
14
|
+
const ref = (0, utils_js_1.run)("git symbolic-ref refs/remotes/origin/HEAD", { cwd: repoPath, quiet: true });
|
|
15
|
+
return ref.replace("refs/remotes/origin/", "");
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
for (const branch of ["master", "main", "develop"]) {
|
|
19
|
+
try {
|
|
20
|
+
(0, utils_js_1.run)(`git rev-parse --verify origin/${branch}`, { cwd: repoPath, quiet: true });
|
|
21
|
+
return branch;
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return "master";
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Check if the current directory is inside a git worktree (not the main repo).
|
|
32
|
+
*/
|
|
33
|
+
function isInWorktree() {
|
|
34
|
+
try {
|
|
35
|
+
const commonDir = (0, utils_js_1.run)("git rev-parse --git-common-dir", { quiet: true }).replace(/\/$/, "");
|
|
36
|
+
const gitDir = (0, utils_js_1.run)("git rev-parse --git-dir", { quiet: true }).replace(/\/$/, "");
|
|
37
|
+
return commonDir !== gitDir;
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Create a snapshot commit of the current worktree state (committed + uncommitted
|
|
45
|
+
* + untracked) without modifying any refs. Returns a SHA that the main repo can
|
|
46
|
+
* checkout via detached HEAD (shared object store across worktrees).
|
|
47
|
+
*/
|
|
48
|
+
function createSnapshot() {
|
|
49
|
+
const cwd = process.cwd();
|
|
50
|
+
// Check if there are uncommitted changes (staged, unstaged, or untracked)
|
|
51
|
+
const status = (0, utils_js_1.run)("git status --porcelain", { cwd, quiet: true });
|
|
52
|
+
if (!status) {
|
|
53
|
+
// No uncommitted changes — use HEAD directly
|
|
54
|
+
const sha = (0, utils_js_1.run)("git rev-parse HEAD", { cwd, quiet: true });
|
|
55
|
+
utils_js_1.log.dim("No uncommitted changes — using HEAD");
|
|
56
|
+
return sha;
|
|
57
|
+
}
|
|
58
|
+
// Stage everything (including untracked files) to capture full state
|
|
59
|
+
(0, utils_js_1.run)("git add -A", { cwd, quiet: true });
|
|
60
|
+
try {
|
|
61
|
+
// Write the current index as a tree object
|
|
62
|
+
const tree = (0, utils_js_1.run)("git write-tree", { cwd, quiet: true });
|
|
63
|
+
// Create a commit object pointing to this tree (no ref update)
|
|
64
|
+
const head = (0, utils_js_1.run)("git rev-parse HEAD", { cwd, quiet: true });
|
|
65
|
+
const sha = (0, utils_js_1.run)(`git commit-tree ${tree} -p ${head} -m "xbq snapshot"`, { cwd, quiet: true });
|
|
66
|
+
utils_js_1.log.dim(`Snapshot: ${sha.slice(0, 8)}`);
|
|
67
|
+
return sha;
|
|
68
|
+
}
|
|
69
|
+
finally {
|
|
70
|
+
// Always restore the index to its original state
|
|
71
|
+
(0, utils_js_1.run)("git reset", { cwd, quiet: true });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Apply a snapshot by checking out the SHA as a detached HEAD in the main repo.
|
|
76
|
+
* Detached HEAD bypasses the worktree branch lock.
|
|
77
|
+
*/
|
|
78
|
+
function applySnapshot(repoPath, sha) {
|
|
79
|
+
utils_js_1.log.info(`Checking out snapshot ${sha.slice(0, 8)}...`);
|
|
80
|
+
try {
|
|
81
|
+
(0, utils_js_1.run)(`git checkout ${sha}`, { cwd: repoPath, quiet: true });
|
|
82
|
+
}
|
|
83
|
+
catch (err) {
|
|
84
|
+
throw new Error(`Failed to checkout snapshot ${sha.slice(0, 8)}: ${err}`);
|
|
85
|
+
}
|
|
86
|
+
utils_js_1.log.ok("Snapshot applied");
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Clean up the main repo by returning to the default branch.
|
|
90
|
+
*/
|
|
91
|
+
function cleanSnapshot(repoPath) {
|
|
92
|
+
const defaultBranch = getDefaultBranch(repoPath);
|
|
93
|
+
try {
|
|
94
|
+
(0, utils_js_1.run)(`git checkout ${defaultBranch}`, { cwd: repoPath, quiet: true });
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
utils_js_1.log.warn(`Could not return to ${defaultBranch}`);
|
|
98
|
+
}
|
|
99
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export interface BQConfig {
|
|
2
|
+
main_repo: string;
|
|
3
|
+
workspace: string;
|
|
4
|
+
default_scheme: string;
|
|
5
|
+
default_test_plan: string;
|
|
6
|
+
default_destination: string;
|
|
7
|
+
backend: "mcp" | "xcodebuild";
|
|
8
|
+
xcodebuild_fallback: boolean;
|
|
9
|
+
git_restore_mtime: boolean;
|
|
10
|
+
}
|
|
11
|
+
export interface Job {
|
|
12
|
+
id: string;
|
|
13
|
+
action: "build" | "test";
|
|
14
|
+
branch?: string;
|
|
15
|
+
snapshot_sha?: string;
|
|
16
|
+
scheme: string;
|
|
17
|
+
test_plan?: string;
|
|
18
|
+
destination?: string;
|
|
19
|
+
backend: "mcp" | "xcodebuild";
|
|
20
|
+
submitted_at: string;
|
|
21
|
+
submitted_by: string;
|
|
22
|
+
}
|
|
23
|
+
export interface JobResult {
|
|
24
|
+
id: string;
|
|
25
|
+
status: "passed" | "failed" | "error";
|
|
26
|
+
duration_seconds: number;
|
|
27
|
+
summary: string;
|
|
28
|
+
failures: string[];
|
|
29
|
+
build_errors: string[];
|
|
30
|
+
warnings: string[];
|
|
31
|
+
log_path: string;
|
|
32
|
+
}
|
|
33
|
+
export declare const DEFAULT_CONFIG: BQConfig;
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.DEFAULT_CONFIG = void 0;
|
|
4
|
+
exports.DEFAULT_CONFIG = {
|
|
5
|
+
main_repo: "",
|
|
6
|
+
workspace: "",
|
|
7
|
+
default_scheme: "",
|
|
8
|
+
default_test_plan: "",
|
|
9
|
+
default_destination: "platform=iOS Simulator,name=iPhone 16",
|
|
10
|
+
backend: "mcp",
|
|
11
|
+
xcodebuild_fallback: true,
|
|
12
|
+
git_restore_mtime: true,
|
|
13
|
+
};
|
package/dist/utils.d.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export declare const BQ_HOME: string;
|
|
2
|
+
export declare const BQ_CONFIG_PATH: string;
|
|
3
|
+
export declare const BQ_QUEUE_DIR: string;
|
|
4
|
+
export declare const BQ_ACTIVE_DIR: string;
|
|
5
|
+
export declare const BQ_RESULTS_DIR: string;
|
|
6
|
+
export declare const BQ_LOGS_DIR: string;
|
|
7
|
+
export declare const BQ_PID_FILE: string;
|
|
8
|
+
export declare const BQ_LOCK_FILE: string;
|
|
9
|
+
export declare const log: {
|
|
10
|
+
info: (msg: string) => void;
|
|
11
|
+
ok: (msg: string) => void;
|
|
12
|
+
warn: (msg: string) => void;
|
|
13
|
+
error: (msg: string) => void;
|
|
14
|
+
status: (msg: string) => void;
|
|
15
|
+
dim: (msg: string) => void;
|
|
16
|
+
};
|
|
17
|
+
export declare function ensureDirs(): void;
|
|
18
|
+
export declare function generateJobId(): string;
|
|
19
|
+
export declare function expandPath(p: string): string;
|
|
20
|
+
/**
|
|
21
|
+
* Run a command and return stdout. Throws on non-zero exit.
|
|
22
|
+
*/
|
|
23
|
+
export declare function run(cmd: string, opts?: {
|
|
24
|
+
cwd?: string;
|
|
25
|
+
quiet?: boolean;
|
|
26
|
+
}): string;
|
|
27
|
+
/**
|
|
28
|
+
* Spawn a long-running process and stream output to a log file + optional callback.
|
|
29
|
+
*/
|
|
30
|
+
export declare function spawnWithLog(cmd: string, args: string[], opts: {
|
|
31
|
+
cwd?: string;
|
|
32
|
+
logPath: string;
|
|
33
|
+
onLine?: (line: string) => void;
|
|
34
|
+
}): Promise<{
|
|
35
|
+
exitCode: number;
|
|
36
|
+
output: string;
|
|
37
|
+
}>;
|
|
38
|
+
/**
|
|
39
|
+
* Check if a process with the given PID is alive.
|
|
40
|
+
*/
|
|
41
|
+
export declare function isProcessAlive(pid: number): boolean;
|