@longshot/cli 0.0.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 +8 -0
- package/dist/agent.js +214 -0
- package/dist/cli.js +172 -0
- package/dist/git.js +291 -0
- package/dist/index.js +1250 -0
- package/dist/profile.js +79 -0
- package/dist/projects.js +337 -0
- package/dist/queue.js +868 -0
- package/dist/services.js +194 -0
- package/dist/store.js +612 -0
- package/dist/views/agent-progress.js +242 -0
- package/dist/views/branches.js +191 -0
- package/dist/views/chat.js +386 -0
- package/dist/views/diff.js +321 -0
- package/dist/views/history.js +124 -0
- package/dist/views/layout.js +121 -0
- package/dist/views/run.js +92 -0
- package/dist/views/services.js +230 -0
- package/dist/views/spec.js +18 -0
- package/dist/views/tasks.js +898 -0
- package/dist/views/verify.js +209 -0
- package/package.json +36 -0
- package/public/style.css +2088 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
Copyright (c) 2026 Rob Sherrill. All Rights Reserved.
|
|
2
|
+
|
|
3
|
+
This software is proprietary and confidential. No part of this software may be
|
|
4
|
+
reproduced, distributed, or transmitted in any form or by any means, without
|
|
5
|
+
the prior written permission of the copyright holder.
|
|
6
|
+
|
|
7
|
+
Unauthorized copying, modification, distribution, or use of this software,
|
|
8
|
+
via any medium, is strictly prohibited.
|
package/dist/agent.js
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { createInterface } from "node:readline";
|
|
3
|
+
export function spawnAgent(task, cwd, timeoutMs = 5 * 60 * 1000, mcpConfig) {
|
|
4
|
+
const args = [
|
|
5
|
+
"@anthropic-ai/claude-code",
|
|
6
|
+
"-p",
|
|
7
|
+
task,
|
|
8
|
+
"--output-format",
|
|
9
|
+
"stream-json",
|
|
10
|
+
"--verbose",
|
|
11
|
+
"--dangerously-skip-permissions",
|
|
12
|
+
];
|
|
13
|
+
if (mcpConfig) {
|
|
14
|
+
args.push("--mcp-config", JSON.stringify(mcpConfig));
|
|
15
|
+
}
|
|
16
|
+
const proc = spawn("npx", args, {
|
|
17
|
+
cwd,
|
|
18
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
19
|
+
env: {
|
|
20
|
+
...process.env,
|
|
21
|
+
CLAUDECODE: undefined, // Clear so it doesn't think it's nested
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
let killed = false;
|
|
25
|
+
const timer = setTimeout(() => {
|
|
26
|
+
killed = true;
|
|
27
|
+
proc.kill("SIGTERM");
|
|
28
|
+
}, timeoutMs);
|
|
29
|
+
const kill = () => {
|
|
30
|
+
killed = true;
|
|
31
|
+
clearTimeout(timer);
|
|
32
|
+
proc.kill("SIGTERM");
|
|
33
|
+
};
|
|
34
|
+
async function* events() {
|
|
35
|
+
const rl = createInterface({ input: proc.stdout });
|
|
36
|
+
// Collect stderr as it arrives
|
|
37
|
+
let stderr = "";
|
|
38
|
+
proc.stderr?.on("data", (chunk) => {
|
|
39
|
+
stderr += chunk.toString();
|
|
40
|
+
});
|
|
41
|
+
for await (const line of rl) {
|
|
42
|
+
if (!line.trim())
|
|
43
|
+
continue;
|
|
44
|
+
let parsed;
|
|
45
|
+
try {
|
|
46
|
+
parsed = JSON.parse(line);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
// stream-json format: each line is a top-level event
|
|
52
|
+
// type: "system" (init), "assistant" (model output), "user" (tool results), "result" (final)
|
|
53
|
+
if (parsed.type === "assistant" && parsed.message?.content) {
|
|
54
|
+
for (const block of parsed.message.content) {
|
|
55
|
+
if (block.type === "text" && block.text) {
|
|
56
|
+
yield { type: "text", content: block.text };
|
|
57
|
+
}
|
|
58
|
+
else if (block.type === "tool_use") {
|
|
59
|
+
yield {
|
|
60
|
+
type: "tool_use",
|
|
61
|
+
name: block.name ?? "unknown",
|
|
62
|
+
id: block.id ?? "",
|
|
63
|
+
input: block.input,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
else if (parsed.type === "user" && parsed.message?.content) {
|
|
69
|
+
// Tool results — extract result content (truncated if large)
|
|
70
|
+
for (const block of parsed.message.content) {
|
|
71
|
+
if (block.type === "tool_result") {
|
|
72
|
+
let content;
|
|
73
|
+
if (typeof block.content === "string") {
|
|
74
|
+
content = block.content.slice(0, 2000);
|
|
75
|
+
}
|
|
76
|
+
else if (Array.isArray(block.content)) {
|
|
77
|
+
const textParts = block.content
|
|
78
|
+
.filter((p) => p.type === "text")
|
|
79
|
+
.map((p) => p.text)
|
|
80
|
+
.join("\n");
|
|
81
|
+
content = textParts.slice(0, 2000);
|
|
82
|
+
}
|
|
83
|
+
yield {
|
|
84
|
+
type: "tool_result",
|
|
85
|
+
name: block.tool_use_id ?? "",
|
|
86
|
+
id: block.tool_use_id ?? "",
|
|
87
|
+
content,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
// Wait for process to exit
|
|
94
|
+
const exitCode = await new Promise((resolve) => {
|
|
95
|
+
if (proc.exitCode !== null) {
|
|
96
|
+
resolve(proc.exitCode);
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
proc.on("close", (code) => resolve(code ?? 1));
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
clearTimeout(timer);
|
|
103
|
+
if (killed) {
|
|
104
|
+
yield { type: "error", message: "Agent timed out" };
|
|
105
|
+
}
|
|
106
|
+
if (exitCode !== 0 && stderr) {
|
|
107
|
+
yield { type: "error", message: stderr.slice(0, 2000) };
|
|
108
|
+
}
|
|
109
|
+
yield { type: "done", exitCode };
|
|
110
|
+
}
|
|
111
|
+
return { events: events(), process: proc, kill };
|
|
112
|
+
}
|
|
113
|
+
export async function chatWithClaude(messages, cwd, opts) {
|
|
114
|
+
const timeoutMs = opts?.timeoutMs ?? 60 * 1000;
|
|
115
|
+
// Build conversation context for the prompt
|
|
116
|
+
const contextMessages = messages.slice(-20); // last 20 for context
|
|
117
|
+
const conversationContext = contextMessages
|
|
118
|
+
.map((m) => `${m.role === "user" ? "User" : "Assistant"}: ${m.content}`)
|
|
119
|
+
.join("\n\n");
|
|
120
|
+
const prompt = opts?.systemPrompt
|
|
121
|
+
? `${opts.systemPrompt}
|
|
122
|
+
|
|
123
|
+
Previous conversation:
|
|
124
|
+
${conversationContext}
|
|
125
|
+
|
|
126
|
+
Respond to the latest user message. Be concise — the user is reading on a mobile phone.`
|
|
127
|
+
: `You are a helpful assistant for a software project. You can read files and search the codebase to answer questions accurately, but do not modify anything. You have access to Read, Glob, Grep, and LS tools for discovery.
|
|
128
|
+
|
|
129
|
+
Previous conversation:
|
|
130
|
+
${conversationContext}
|
|
131
|
+
|
|
132
|
+
Respond to the latest user message. Be concise — the user is reading on a mobile phone.`;
|
|
133
|
+
const readOnlyTools = ["Read", "Glob", "Grep", "LS"];
|
|
134
|
+
const args = [
|
|
135
|
+
"@anthropic-ai/claude-code",
|
|
136
|
+
"-p",
|
|
137
|
+
prompt,
|
|
138
|
+
"--output-format",
|
|
139
|
+
"stream-json",
|
|
140
|
+
"--verbose",
|
|
141
|
+
"--allowedTools",
|
|
142
|
+
readOnlyTools.join(","),
|
|
143
|
+
];
|
|
144
|
+
const proc = spawn("npx", args, {
|
|
145
|
+
cwd,
|
|
146
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
147
|
+
env: {
|
|
148
|
+
...process.env,
|
|
149
|
+
CLAUDECODE: undefined,
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
let killed = false;
|
|
153
|
+
const timer = setTimeout(() => {
|
|
154
|
+
killed = true;
|
|
155
|
+
proc.kill("SIGTERM");
|
|
156
|
+
}, timeoutMs);
|
|
157
|
+
return new Promise((resolve, reject) => {
|
|
158
|
+
let fullText = "";
|
|
159
|
+
const rl = createInterface({ input: proc.stdout });
|
|
160
|
+
let stderr = "";
|
|
161
|
+
proc.stderr?.on("data", (chunk) => {
|
|
162
|
+
stderr += chunk.toString();
|
|
163
|
+
});
|
|
164
|
+
rl.on("line", (line) => {
|
|
165
|
+
if (!line.trim())
|
|
166
|
+
return;
|
|
167
|
+
try {
|
|
168
|
+
const parsed = JSON.parse(line);
|
|
169
|
+
if (parsed.type === "assistant" && parsed.message?.content) {
|
|
170
|
+
for (const block of parsed.message.content) {
|
|
171
|
+
if (block.type === "text" && block.text) {
|
|
172
|
+
fullText += block.text;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
else if (parsed.type === "result" && parsed.result) {
|
|
177
|
+
// Final result text
|
|
178
|
+
if (!fullText && typeof parsed.result === "string") {
|
|
179
|
+
fullText = parsed.result;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
catch (e) {
|
|
184
|
+
console.error("chatWithClaude: failed to parse JSON line:", e.message, "line:", line.slice(0, 200));
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
proc.on("close", (code) => {
|
|
188
|
+
clearTimeout(timer);
|
|
189
|
+
if (stderr) {
|
|
190
|
+
console.error("chatWithClaude stderr:", stderr.slice(0, 2000));
|
|
191
|
+
}
|
|
192
|
+
if (killed) {
|
|
193
|
+
resolve(fullText || "Sorry, the response timed out.");
|
|
194
|
+
}
|
|
195
|
+
else if (fullText) {
|
|
196
|
+
resolve(fullText);
|
|
197
|
+
}
|
|
198
|
+
else {
|
|
199
|
+
console.error(`chatWithClaude: no text output (exit code ${code})`);
|
|
200
|
+
const stderrSummary = stderr.trim().slice(-500);
|
|
201
|
+
if (stderrSummary) {
|
|
202
|
+
resolve(`Error from Claude CLI (exit code ${code}):\n${stderrSummary}`);
|
|
203
|
+
}
|
|
204
|
+
else {
|
|
205
|
+
resolve(`Sorry, I couldn't generate a response (exit code ${code}).`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
proc.on("error", (err) => {
|
|
210
|
+
clearTimeout(timer);
|
|
211
|
+
reject(err);
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
}
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { parseArgs } from "node:util";
|
|
3
|
+
import { resolve, join, dirname } from "node:path";
|
|
4
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
function getVersion() {
|
|
8
|
+
// Walk up from src/ or dist/ to find package.json
|
|
9
|
+
const candidates = [
|
|
10
|
+
join(__dirname, "..", "package.json"),
|
|
11
|
+
join(__dirname, "..", "..", "package.json"),
|
|
12
|
+
];
|
|
13
|
+
for (const p of candidates) {
|
|
14
|
+
if (existsSync(p)) {
|
|
15
|
+
return JSON.parse(readFileSync(p, "utf-8")).version || "unknown";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return "unknown";
|
|
19
|
+
}
|
|
20
|
+
function printHelp() {
|
|
21
|
+
console.log(`longshot — Mobile-first Claude Code orchestrator
|
|
22
|
+
|
|
23
|
+
Usage: longshot [options]
|
|
24
|
+
|
|
25
|
+
Options:
|
|
26
|
+
-p, --port <number> Port for the web server (default: 3333)
|
|
27
|
+
-d, --dir <path> Project root directory (default: current directory)
|
|
28
|
+
-h, --help Show this help message
|
|
29
|
+
-v, --version Show version number
|
|
30
|
+
|
|
31
|
+
Examples:
|
|
32
|
+
longshot Start in current directory on port 3333
|
|
33
|
+
longshot -p 8080 Start on port 8080
|
|
34
|
+
longshot -d ~/my-project Start with a specific project directory
|
|
35
|
+
|
|
36
|
+
First run:
|
|
37
|
+
If the target directory isn't a longshot project yet, you'll be guided
|
|
38
|
+
through setup. Just run \`longshot\` and follow the prompts.`);
|
|
39
|
+
}
|
|
40
|
+
export function parseCliArgs(args) {
|
|
41
|
+
const { values } = parseArgs({
|
|
42
|
+
args,
|
|
43
|
+
options: {
|
|
44
|
+
port: { type: "string", short: "p" },
|
|
45
|
+
dir: { type: "string", short: "d" },
|
|
46
|
+
help: { type: "boolean", short: "h", default: false },
|
|
47
|
+
version: { type: "boolean", short: "v", default: false },
|
|
48
|
+
},
|
|
49
|
+
strict: true,
|
|
50
|
+
allowPositionals: false,
|
|
51
|
+
});
|
|
52
|
+
return {
|
|
53
|
+
port: values.port ? parseInt(values.port, 10) : 3333,
|
|
54
|
+
dir: values.dir ? resolve(values.dir) : process.cwd(),
|
|
55
|
+
help: values.help ?? false,
|
|
56
|
+
version: values.version ?? false,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
class InquirerPrompter {
|
|
60
|
+
async selectMode() {
|
|
61
|
+
const inquirer = await import("inquirer");
|
|
62
|
+
try {
|
|
63
|
+
const { mode } = await inquirer.default.prompt([
|
|
64
|
+
{
|
|
65
|
+
type: "list",
|
|
66
|
+
name: "mode",
|
|
67
|
+
message: "How would you like to use longshot?",
|
|
68
|
+
choices: [
|
|
69
|
+
{ name: "Single project — initialize this directory", value: "single" },
|
|
70
|
+
{ name: "Multi-project — manage multiple subdirectories", value: "multi" },
|
|
71
|
+
],
|
|
72
|
+
},
|
|
73
|
+
]);
|
|
74
|
+
return mode;
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
async selectFolders(folders) {
|
|
81
|
+
const inquirer = await import("inquirer");
|
|
82
|
+
try {
|
|
83
|
+
const { selected } = await inquirer.default.prompt([
|
|
84
|
+
{
|
|
85
|
+
type: "checkbox",
|
|
86
|
+
name: "selected",
|
|
87
|
+
message: "Select project directories:",
|
|
88
|
+
choices: folders.map((f) => ({ name: f, value: f })),
|
|
89
|
+
},
|
|
90
|
+
]);
|
|
91
|
+
return selected;
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return [];
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
export async function runOnboarding(dir, prompter) {
|
|
99
|
+
// Check if it already has .longshot/ (single project)
|
|
100
|
+
if (existsSync(join(dir, ".longshot"))) {
|
|
101
|
+
return { action: "existing" };
|
|
102
|
+
}
|
|
103
|
+
// Check for multi-project mode (parent with projects.json)
|
|
104
|
+
if (existsSync(join(dir, ".longshot", "projects.json"))) {
|
|
105
|
+
return { action: "existing" };
|
|
106
|
+
}
|
|
107
|
+
// Non-interactive (no prompter injected and stdin is not a TTY) — auto-initialize as single project
|
|
108
|
+
if (!prompter && !process.stdin.isTTY) {
|
|
109
|
+
const { initSingleProject } = await import("./projects.js");
|
|
110
|
+
initSingleProject(dir);
|
|
111
|
+
return { action: "single" };
|
|
112
|
+
}
|
|
113
|
+
const prompt = prompter ?? new InquirerPrompter();
|
|
114
|
+
const mode = await prompt.selectMode();
|
|
115
|
+
if (mode === null) {
|
|
116
|
+
console.log('Run `longshot --help` for usage');
|
|
117
|
+
return { action: "cancelled" };
|
|
118
|
+
}
|
|
119
|
+
if (mode === "single") {
|
|
120
|
+
const { initSingleProject } = await import("./projects.js");
|
|
121
|
+
initSingleProject(dir);
|
|
122
|
+
console.log("Initialized longshot project in", dir);
|
|
123
|
+
return { action: "single" };
|
|
124
|
+
}
|
|
125
|
+
if (mode === "multi") {
|
|
126
|
+
// List subdirectories
|
|
127
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
128
|
+
const folders = entries
|
|
129
|
+
.filter((e) => e.isDirectory() && !e.name.startsWith(".") && e.name !== "node_modules")
|
|
130
|
+
.map((e) => e.name);
|
|
131
|
+
if (folders.length === 0) {
|
|
132
|
+
console.log("No subdirectories found. Create some project folders first.");
|
|
133
|
+
return { action: "cancelled" };
|
|
134
|
+
}
|
|
135
|
+
const selected = await prompt.selectFolders(folders);
|
|
136
|
+
if (selected.length === 0) {
|
|
137
|
+
console.log('Run `longshot --help` for usage');
|
|
138
|
+
return { action: "cancelled" };
|
|
139
|
+
}
|
|
140
|
+
const { initMultiProject } = await import("./projects.js");
|
|
141
|
+
initMultiProject(dir, selected);
|
|
142
|
+
console.log(`Initialized ${selected.length} project(s):`, selected.join(", "));
|
|
143
|
+
return { action: "multi", selected };
|
|
144
|
+
}
|
|
145
|
+
return { action: "cancelled" };
|
|
146
|
+
}
|
|
147
|
+
async function main() {
|
|
148
|
+
const parsed = parseCliArgs(process.argv.slice(2));
|
|
149
|
+
if (parsed.help) {
|
|
150
|
+
printHelp();
|
|
151
|
+
process.exit(0);
|
|
152
|
+
}
|
|
153
|
+
if (parsed.version) {
|
|
154
|
+
console.log(getVersion());
|
|
155
|
+
process.exit(0);
|
|
156
|
+
}
|
|
157
|
+
// Run onboarding if needed
|
|
158
|
+
const result = await runOnboarding(parsed.dir);
|
|
159
|
+
if (result.action === "cancelled") {
|
|
160
|
+
process.exit(0);
|
|
161
|
+
}
|
|
162
|
+
// Set environment for the server
|
|
163
|
+
process.env.PORT = String(parsed.port);
|
|
164
|
+
process.env.PROJECT_ROOT = parsed.dir;
|
|
165
|
+
// Start the server
|
|
166
|
+
const { startServer } = await import("./index.js");
|
|
167
|
+
await startServer();
|
|
168
|
+
}
|
|
169
|
+
main().catch((err) => {
|
|
170
|
+
console.error("Fatal error:", err);
|
|
171
|
+
process.exit(1);
|
|
172
|
+
});
|
package/dist/git.js
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
const exec = promisify(execFile);
|
|
6
|
+
async function git(args, cwd) {
|
|
7
|
+
return exec("git", args, { cwd, maxBuffer: 10 * 1024 * 1024 });
|
|
8
|
+
}
|
|
9
|
+
export async function ensureRepo(cwd) {
|
|
10
|
+
if (!existsSync(join(cwd, ".git"))) {
|
|
11
|
+
await git(["init"], cwd);
|
|
12
|
+
// Initial commit so we have a HEAD to diff against
|
|
13
|
+
await git(["commit", "--allow-empty", "-m", "init"], cwd);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export async function snapshot(cwd) {
|
|
17
|
+
const { stdout } = await git(["rev-parse", "HEAD"], cwd);
|
|
18
|
+
return stdout.trim();
|
|
19
|
+
}
|
|
20
|
+
export async function diff(cwd, fromHash) {
|
|
21
|
+
// Stage everything so we capture new files too
|
|
22
|
+
await git(["add", "-A"], cwd);
|
|
23
|
+
const { stdout } = await git([
|
|
24
|
+
"diff",
|
|
25
|
+
"--cached",
|
|
26
|
+
fromHash,
|
|
27
|
+
"--",
|
|
28
|
+
".",
|
|
29
|
+
":!CLAUDE.md",
|
|
30
|
+
":!.longshot/",
|
|
31
|
+
], cwd);
|
|
32
|
+
// Unstage so we don't interfere with the working tree
|
|
33
|
+
await git(["reset", "HEAD"], cwd);
|
|
34
|
+
return stdout;
|
|
35
|
+
}
|
|
36
|
+
export async function commit(cwd, message) {
|
|
37
|
+
await git(["add", "-A"], cwd);
|
|
38
|
+
await git(["commit", "-m", message], cwd);
|
|
39
|
+
const { stdout } = await git(["rev-parse", "HEAD"], cwd);
|
|
40
|
+
return stdout.trim();
|
|
41
|
+
}
|
|
42
|
+
export async function reset(cwd, toHash) {
|
|
43
|
+
await git(["reset", "--hard", toHash], cwd);
|
|
44
|
+
await git(["clean", "-fd"], cwd);
|
|
45
|
+
}
|
|
46
|
+
export async function hasUncommittedChanges(cwd) {
|
|
47
|
+
const { stdout } = await git(["status", "--porcelain"], cwd);
|
|
48
|
+
return stdout.trim().length > 0;
|
|
49
|
+
}
|
|
50
|
+
export async function stashSave(cwd, message) {
|
|
51
|
+
const { stdout: statusOut } = await git(["status", "--porcelain"], cwd);
|
|
52
|
+
if (!statusOut.trim())
|
|
53
|
+
return null;
|
|
54
|
+
// Parse changed files, filtering out .longshot/ and CLAUDE.md
|
|
55
|
+
const files = [];
|
|
56
|
+
for (const line of statusOut.split("\n")) {
|
|
57
|
+
if (!line.trim())
|
|
58
|
+
continue;
|
|
59
|
+
// Porcelain format: XY filename (exactly 2 status chars + 1 space + filename)
|
|
60
|
+
const filePart = line.slice(3);
|
|
61
|
+
const file = filePart.includes(" -> ") ? filePart.split(" -> ")[1] : filePart;
|
|
62
|
+
if (!file.startsWith(".longshot/") && file !== "CLAUDE.md") {
|
|
63
|
+
files.push(file);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (files.length === 0)
|
|
67
|
+
return null;
|
|
68
|
+
await git(["stash", "push", "--include-untracked", "-m", message, "--", ...files], cwd);
|
|
69
|
+
const { stdout: hashOut } = await git(["rev-parse", "stash@{0}"], cwd);
|
|
70
|
+
return hashOut.trim();
|
|
71
|
+
}
|
|
72
|
+
export async function stashApply(cwd, hash) {
|
|
73
|
+
await git(["stash", "apply", hash], cwd);
|
|
74
|
+
}
|
|
75
|
+
/** Apply stash allowing conflicts — writes conflict markers to disk instead of aborting */
|
|
76
|
+
export async function stashApplyAllowConflicts(cwd, hash) {
|
|
77
|
+
try {
|
|
78
|
+
await git(["stash", "apply", hash], cwd);
|
|
79
|
+
return { conflicts: [] };
|
|
80
|
+
}
|
|
81
|
+
catch (err) {
|
|
82
|
+
// Check if files were partially applied with conflict markers
|
|
83
|
+
const { stdout } = await git(["status", "--porcelain"], cwd);
|
|
84
|
+
const conflicts = [];
|
|
85
|
+
for (const line of stdout.split("\n")) {
|
|
86
|
+
if (!line.trim())
|
|
87
|
+
continue;
|
|
88
|
+
// "UU" = both modified (conflict), "AA" = both added, "DU"/"UD" = delete/modify conflicts
|
|
89
|
+
const status = line.slice(0, 2);
|
|
90
|
+
if (status === "UU" || status === "AA" || status === "DU" || status === "UD") {
|
|
91
|
+
conflicts.push(line.slice(3));
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (conflicts.length > 0) {
|
|
95
|
+
// Conflicts were written to disk — add conflicted files so they're tracked
|
|
96
|
+
await git(["add", "-A"], cwd);
|
|
97
|
+
return { conflicts };
|
|
98
|
+
}
|
|
99
|
+
// No conflicts detected — the error was something else
|
|
100
|
+
throw err;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
export async function stashDrop(cwd, hash) {
|
|
104
|
+
const { stdout: listOut } = await git(["stash", "list", "--format=%H %gd"], cwd);
|
|
105
|
+
for (const line of listOut.trim().split("\n")) {
|
|
106
|
+
if (!line.trim())
|
|
107
|
+
continue;
|
|
108
|
+
const parts = line.split(" ");
|
|
109
|
+
const stashHash = parts[0];
|
|
110
|
+
const ref = parts[1];
|
|
111
|
+
if (stashHash === hash) {
|
|
112
|
+
await git(["stash", "drop", ref], cwd);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
// Not found — already dropped, idempotent
|
|
117
|
+
}
|
|
118
|
+
export async function getRemoteStatus(cwd) {
|
|
119
|
+
const { stdout: branchOut } = await git(["rev-parse", "--abbrev-ref", "HEAD"], cwd);
|
|
120
|
+
const branch = branchOut.trim();
|
|
121
|
+
// Check if there's a remote tracking branch
|
|
122
|
+
let remote = null;
|
|
123
|
+
try {
|
|
124
|
+
const { stdout: remoteOut } = await git(["config", "--get", `branch.${branch}.remote`], cwd);
|
|
125
|
+
remote = remoteOut.trim() || null;
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
return { ahead: 0, behind: 0, remote: null, branch };
|
|
129
|
+
}
|
|
130
|
+
// Fetch latest from remote
|
|
131
|
+
try {
|
|
132
|
+
await git(["fetch", remote], cwd);
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
// Fetch failed (no network, etc.) — continue with local state
|
|
136
|
+
}
|
|
137
|
+
const upstream = `${remote}/${branch}`;
|
|
138
|
+
let ahead = 0;
|
|
139
|
+
let behind = 0;
|
|
140
|
+
try {
|
|
141
|
+
const { stdout: aheadOut } = await git(["rev-list", "--count", `${upstream}..HEAD`], cwd);
|
|
142
|
+
ahead = parseInt(aheadOut.trim(), 10) || 0;
|
|
143
|
+
const { stdout: behindOut } = await git(["rev-list", "--count", `HEAD..${upstream}`], cwd);
|
|
144
|
+
behind = parseInt(behindOut.trim(), 10) || 0;
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
// Upstream branch doesn't exist yet — treat as no remote
|
|
148
|
+
return { ahead: 0, behind: 0, remote: null, branch };
|
|
149
|
+
}
|
|
150
|
+
return { ahead, behind, remote, branch };
|
|
151
|
+
}
|
|
152
|
+
export async function getOutgoingCommits(cwd) {
|
|
153
|
+
try {
|
|
154
|
+
const { stdout } = await git(["log", "@{u}..HEAD", "--format=%h %s"], cwd);
|
|
155
|
+
if (!stdout.trim())
|
|
156
|
+
return [];
|
|
157
|
+
return stdout
|
|
158
|
+
.trim()
|
|
159
|
+
.split("\n")
|
|
160
|
+
.map((line) => {
|
|
161
|
+
const spaceIdx = line.indexOf(" ");
|
|
162
|
+
return {
|
|
163
|
+
hash: line.slice(0, spaceIdx),
|
|
164
|
+
subject: line.slice(spaceIdx + 1),
|
|
165
|
+
};
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
return [];
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
export async function push(cwd) {
|
|
173
|
+
try {
|
|
174
|
+
await git(["push"], cwd);
|
|
175
|
+
return { success: true };
|
|
176
|
+
}
|
|
177
|
+
catch (err) {
|
|
178
|
+
return { success: false, error: err.stderr || err.message };
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
export async function pull(cwd) {
|
|
182
|
+
try {
|
|
183
|
+
await git(["pull", "--ff-only"], cwd);
|
|
184
|
+
return { success: true };
|
|
185
|
+
}
|
|
186
|
+
catch (err) {
|
|
187
|
+
return { success: false, error: err.stderr || err.message };
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
export async function pullRebase(cwd) {
|
|
191
|
+
try {
|
|
192
|
+
await git(["pull", "--rebase"], cwd);
|
|
193
|
+
return { success: true };
|
|
194
|
+
}
|
|
195
|
+
catch (err) {
|
|
196
|
+
return { success: false, error: err.stderr || err.message };
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
// --- Branch operations ---
|
|
200
|
+
export async function getCurrentBranch(cwd) {
|
|
201
|
+
const { stdout } = await git(["rev-parse", "--abbrev-ref", "HEAD"], cwd);
|
|
202
|
+
return stdout.trim();
|
|
203
|
+
}
|
|
204
|
+
export async function createBranch(cwd, name) {
|
|
205
|
+
await git(["checkout", "-b", name], cwd);
|
|
206
|
+
}
|
|
207
|
+
export async function switchBranch(cwd, name) {
|
|
208
|
+
await git(["checkout", name], cwd);
|
|
209
|
+
}
|
|
210
|
+
export async function listBranches(cwd) {
|
|
211
|
+
const { stdout } = await git(["branch", "--list"], cwd);
|
|
212
|
+
const branches = [];
|
|
213
|
+
let current = "";
|
|
214
|
+
for (const line of stdout.split("\n")) {
|
|
215
|
+
if (!line.trim())
|
|
216
|
+
continue;
|
|
217
|
+
const isCurrent = line.startsWith("* ");
|
|
218
|
+
const name = line.replace(/^\*?\s+/, "").trim();
|
|
219
|
+
branches.push(name);
|
|
220
|
+
if (isCurrent)
|
|
221
|
+
current = name;
|
|
222
|
+
}
|
|
223
|
+
return { local: branches, current };
|
|
224
|
+
}
|
|
225
|
+
export async function mergeBranch(cwd, source, target) {
|
|
226
|
+
// Check out target branch
|
|
227
|
+
await git(["checkout", target], cwd);
|
|
228
|
+
try {
|
|
229
|
+
await git(["merge", source, "--no-edit"], cwd);
|
|
230
|
+
const { stdout } = await git(["rev-parse", "HEAD"], cwd);
|
|
231
|
+
return { success: true, commitHash: stdout.trim() };
|
|
232
|
+
}
|
|
233
|
+
catch (err) {
|
|
234
|
+
// Check for merge conflicts
|
|
235
|
+
const { stdout: statusOut } = await git(["status", "--porcelain"], cwd);
|
|
236
|
+
const conflicts = [];
|
|
237
|
+
for (const line of statusOut.split("\n")) {
|
|
238
|
+
if (!line.trim())
|
|
239
|
+
continue;
|
|
240
|
+
const status = line.slice(0, 2);
|
|
241
|
+
if (status === "UU" || status === "AA" || status === "DU" || status === "UD") {
|
|
242
|
+
conflicts.push(line.slice(3));
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
if (conflicts.length > 0) {
|
|
246
|
+
// Abort the merge
|
|
247
|
+
await git(["merge", "--abort"], cwd);
|
|
248
|
+
return { success: false, conflicts };
|
|
249
|
+
}
|
|
250
|
+
return { success: false, error: err.stderr || err.message };
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
export async function deleteBranch(cwd, name) {
|
|
254
|
+
try {
|
|
255
|
+
await git(["branch", "-d", name], cwd);
|
|
256
|
+
return { success: true };
|
|
257
|
+
}
|
|
258
|
+
catch (err) {
|
|
259
|
+
return { success: false, error: err.stderr || err.message };
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
export async function pushBranch(cwd, name) {
|
|
263
|
+
try {
|
|
264
|
+
await git(["push", "-u", "origin", name], cwd);
|
|
265
|
+
return { success: true };
|
|
266
|
+
}
|
|
267
|
+
catch (err) {
|
|
268
|
+
return { success: false, error: err.stderr || err.message };
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
export async function commitFiles(cwd, message, files) {
|
|
272
|
+
try {
|
|
273
|
+
await git(["add", "--", ...files], cwd);
|
|
274
|
+
}
|
|
275
|
+
catch {
|
|
276
|
+
// Files may not exist — nothing to add
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
try {
|
|
280
|
+
await git(["commit", "-m", message], cwd);
|
|
281
|
+
}
|
|
282
|
+
catch (err) {
|
|
283
|
+
// "nothing to commit" is fine
|
|
284
|
+
if (err.stderr?.includes("nothing to commit") || err.stdout?.includes("nothing to commit") || err.message?.includes("nothing to commit")) {
|
|
285
|
+
return null;
|
|
286
|
+
}
|
|
287
|
+
throw err;
|
|
288
|
+
}
|
|
289
|
+
const { stdout } = await git(["rev-parse", "HEAD"], cwd);
|
|
290
|
+
return stdout.trim();
|
|
291
|
+
}
|