@inteeka/task-cli 0.2.5 → 0.2.7
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/dist/cli.js +600 -267
- package/dist/cli.js.map +1 -1
- package/package.json +2 -2
package/dist/cli.js
CHANGED
|
@@ -998,9 +998,243 @@ function printTable(headers, rows) {
|
|
|
998
998
|
}
|
|
999
999
|
|
|
1000
1000
|
// src/commands/status.ts
|
|
1001
|
+
import { execFileSync as execFileSync3 } from "child_process";
|
|
1002
|
+
|
|
1003
|
+
// src/git/branch.ts
|
|
1004
|
+
import { execFileSync as execFileSync2 } from "child_process";
|
|
1005
|
+
|
|
1006
|
+
// src/git/commit.ts
|
|
1001
1007
|
import { execFileSync } from "child_process";
|
|
1008
|
+
function commitOnly(args) {
|
|
1009
|
+
execFileSync("git", ["add", "-A"], { cwd: args.cwd });
|
|
1010
|
+
const statusRaw = execFileSync("git", ["status", "--porcelain"], {
|
|
1011
|
+
cwd: args.cwd,
|
|
1012
|
+
encoding: "utf8"
|
|
1013
|
+
});
|
|
1014
|
+
if (!statusRaw.trim()) {
|
|
1015
|
+
throw new Error("No changes to commit (empty diff)");
|
|
1016
|
+
}
|
|
1017
|
+
execFileSync("git", ["commit", "-m", args.message], { cwd: args.cwd });
|
|
1018
|
+
const sha = execFileSync("git", ["rev-parse", "HEAD"], {
|
|
1019
|
+
cwd: args.cwd,
|
|
1020
|
+
encoding: "utf8"
|
|
1021
|
+
}).trim();
|
|
1022
|
+
return { sha };
|
|
1023
|
+
}
|
|
1024
|
+
function currentBranch(cwd) {
|
|
1025
|
+
try {
|
|
1026
|
+
return execFileSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
|
|
1027
|
+
cwd,
|
|
1028
|
+
encoding: "utf8"
|
|
1029
|
+
}).trim();
|
|
1030
|
+
} catch {
|
|
1031
|
+
return "HEAD";
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
// src/git/branch.ts
|
|
1036
|
+
var VALID_BRANCH = /^[A-Za-z0-9._/-]{1,200}$/;
|
|
1037
|
+
var TICKET_BRANCH = /^task\/[a-z0-9-]{1,80}$/;
|
|
1038
|
+
function assertValidBranchName(branch) {
|
|
1039
|
+
if (!VALID_BRANCH.test(branch) || branch.includes("..") || branch.startsWith("/") || branch.endsWith("/")) {
|
|
1040
|
+
throw new CliError(
|
|
1041
|
+
CLI_EXIT_CODES.MISCONFIGURATION,
|
|
1042
|
+
`Invalid branch name: ${branch}`,
|
|
1043
|
+
'Branch names must contain only [A-Za-z0-9._/-], no "..", and no leading/trailing slash.'
|
|
1044
|
+
);
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
function isWorkingTreeClean(cwd) {
|
|
1048
|
+
const out = execFileSync2("git", ["status", "--porcelain"], {
|
|
1049
|
+
cwd,
|
|
1050
|
+
encoding: "utf8"
|
|
1051
|
+
});
|
|
1052
|
+
return out.trim().length === 0;
|
|
1053
|
+
}
|
|
1054
|
+
function assertBaseBranch(cwd, expected) {
|
|
1055
|
+
assertValidBranchName(expected);
|
|
1056
|
+
const current = currentBranch(cwd);
|
|
1057
|
+
if (current !== expected) {
|
|
1058
|
+
throw new CliError(
|
|
1059
|
+
CLI_EXIT_CODES.MISCONFIGURATION,
|
|
1060
|
+
`task work requires branch "${expected}" but you're on "${current}"`,
|
|
1061
|
+
`Run "git checkout ${expected}" first. The base branch is configured per project; ask an admin if it should be different.`
|
|
1062
|
+
);
|
|
1063
|
+
}
|
|
1064
|
+
if (!isWorkingTreeClean(cwd)) {
|
|
1065
|
+
throw new CliError(
|
|
1066
|
+
CLI_EXIT_CODES.MISCONFIGURATION,
|
|
1067
|
+
"Working tree is dirty",
|
|
1068
|
+
"Commit, stash, or discard your local changes before running task work."
|
|
1069
|
+
);
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
function createTicketBranch(cwd, branchName, baseBranch) {
|
|
1073
|
+
assertValidBranchName(branchName);
|
|
1074
|
+
assertValidBranchName(baseBranch);
|
|
1075
|
+
if (!TICKET_BRANCH.test(branchName)) {
|
|
1076
|
+
throw new CliError(
|
|
1077
|
+
CLI_EXIT_CODES.MISCONFIGURATION,
|
|
1078
|
+
`Per-ticket branch must match ^task/[a-z0-9-]{1,80}$ \u2014 got "${branchName}"`
|
|
1079
|
+
);
|
|
1080
|
+
}
|
|
1081
|
+
try {
|
|
1082
|
+
execFileSync2("git", ["checkout", "-b", branchName, baseBranch], {
|
|
1083
|
+
cwd,
|
|
1084
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
1085
|
+
});
|
|
1086
|
+
} catch (err) {
|
|
1087
|
+
const stderr = err.stderr?.toString("utf8") ?? "";
|
|
1088
|
+
throw new CliError(
|
|
1089
|
+
CLI_EXIT_CODES.GENERIC_ERROR,
|
|
1090
|
+
`Could not create branch ${branchName}: ${stderr.slice(0, 400) || err.message}`
|
|
1091
|
+
);
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
function deleteLocalBranch(cwd, branchName) {
|
|
1095
|
+
if (!VALID_BRANCH.test(branchName)) return;
|
|
1096
|
+
try {
|
|
1097
|
+
execFileSync2("git", ["branch", "-D", branchName], {
|
|
1098
|
+
cwd,
|
|
1099
|
+
stdio: ["ignore", "ignore", "ignore"]
|
|
1100
|
+
});
|
|
1101
|
+
} catch {
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
function checkoutBranch(cwd, branchName) {
|
|
1105
|
+
assertValidBranchName(branchName);
|
|
1106
|
+
try {
|
|
1107
|
+
execFileSync2("git", ["checkout", branchName], {
|
|
1108
|
+
cwd,
|
|
1109
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
1110
|
+
});
|
|
1111
|
+
} catch (err) {
|
|
1112
|
+
const stderr = err.stderr?.toString("utf8") ?? "";
|
|
1113
|
+
throw new CliError(
|
|
1114
|
+
CLI_EXIT_CODES.GENERIC_ERROR,
|
|
1115
|
+
`Could not check out branch ${branchName}: ${stderr.slice(0, 400) || err.message}`
|
|
1116
|
+
);
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
function pushBranch(cwd, branchName) {
|
|
1120
|
+
assertValidBranchName(branchName);
|
|
1121
|
+
try {
|
|
1122
|
+
execFileSync2("git", ["push", "-u", "origin", branchName], {
|
|
1123
|
+
cwd,
|
|
1124
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
1125
|
+
});
|
|
1126
|
+
return { remote: "origin" };
|
|
1127
|
+
} catch (err) {
|
|
1128
|
+
const stderr = err.stderr?.toString("utf8") ?? "";
|
|
1129
|
+
throw new CliError(
|
|
1130
|
+
CLI_EXIT_CODES.GENERIC_ERROR,
|
|
1131
|
+
`Push failed: ${stderr.slice(0, 600) || err.message}`
|
|
1132
|
+
);
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
function enforceBaseBranchClean(cwd, baseBranch, opts = {}) {
|
|
1136
|
+
assertValidBranchName(baseBranch);
|
|
1137
|
+
try {
|
|
1138
|
+
execFileSync2("git", ["restore", "--staged", "--worktree", "."], {
|
|
1139
|
+
cwd,
|
|
1140
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
1141
|
+
});
|
|
1142
|
+
} catch {
|
|
1143
|
+
try {
|
|
1144
|
+
execFileSync2("git", ["reset", "--hard", "HEAD"], {
|
|
1145
|
+
cwd,
|
|
1146
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
1147
|
+
});
|
|
1148
|
+
} catch {
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
try {
|
|
1152
|
+
execFileSync2("git", ["clean", "-fd"], {
|
|
1153
|
+
cwd,
|
|
1154
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
1155
|
+
});
|
|
1156
|
+
} catch {
|
|
1157
|
+
}
|
|
1158
|
+
checkoutBranch(cwd, baseBranch);
|
|
1159
|
+
const current = currentBranch(cwd);
|
|
1160
|
+
if (current !== baseBranch) {
|
|
1161
|
+
throw new CliError(
|
|
1162
|
+
CLI_EXIT_CODES.GENERIC_ERROR,
|
|
1163
|
+
`Branch enforcement failed: expected "${baseBranch}", on "${current}"`,
|
|
1164
|
+
`Run "git checkout ${baseBranch}" manually, then re-run task multi-work.`
|
|
1165
|
+
);
|
|
1166
|
+
}
|
|
1167
|
+
if (!isWorkingTreeClean(cwd)) {
|
|
1168
|
+
throw new CliError(
|
|
1169
|
+
CLI_EXIT_CODES.GENERIC_ERROR,
|
|
1170
|
+
"Branch enforcement failed: working tree is dirty after returning to base",
|
|
1171
|
+
'Inspect with "git status" and clean up before re-running task multi-work.'
|
|
1172
|
+
);
|
|
1173
|
+
}
|
|
1174
|
+
if (opts.deleteBranch && opts.deleteBranch !== baseBranch) {
|
|
1175
|
+
deleteLocalBranch(cwd, opts.deleteBranch);
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
function remoteBranchExists(cwd, branchName, opts = {}) {
|
|
1179
|
+
assertValidBranchName(branchName);
|
|
1180
|
+
const remote = opts.remote ?? "origin";
|
|
1181
|
+
if (!/^[A-Za-z0-9._-]{1,100}$/.test(remote)) {
|
|
1182
|
+
throw new CliError(CLI_EXIT_CODES.MISCONFIGURATION, `Invalid remote name: ${remote}`);
|
|
1183
|
+
}
|
|
1184
|
+
let stdout;
|
|
1185
|
+
try {
|
|
1186
|
+
stdout = execFileSync2("git", ["ls-remote", "--heads", remote, branchName], {
|
|
1187
|
+
cwd,
|
|
1188
|
+
encoding: "utf8",
|
|
1189
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
1190
|
+
});
|
|
1191
|
+
} catch (err) {
|
|
1192
|
+
const stderr = err.stderr?.toString("utf8") ?? "";
|
|
1193
|
+
throw new CliError(
|
|
1194
|
+
CLI_EXIT_CODES.NETWORK_UNREACHABLE,
|
|
1195
|
+
`Could not reach remote "${remote}": ${stderr.slice(0, 400) || err.message}`,
|
|
1196
|
+
"Check that the remote exists (`git remote -v`) and that you have credentials to talk to it."
|
|
1197
|
+
);
|
|
1198
|
+
}
|
|
1199
|
+
return stdout.trim().length > 0;
|
|
1200
|
+
}
|
|
1201
|
+
function listLocalTicketBranches(cwd) {
|
|
1202
|
+
let stdout;
|
|
1203
|
+
try {
|
|
1204
|
+
stdout = execFileSync2(
|
|
1205
|
+
"git",
|
|
1206
|
+
["for-each-ref", "--format=%(refname:short) %(upstream:short)", "refs/heads/task/"],
|
|
1207
|
+
{ cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }
|
|
1208
|
+
);
|
|
1209
|
+
} catch {
|
|
1210
|
+
return [];
|
|
1211
|
+
}
|
|
1212
|
+
const out = [];
|
|
1213
|
+
for (const rawLine of stdout.split("\n")) {
|
|
1214
|
+
const line = rawLine.trim();
|
|
1215
|
+
if (line.length === 0) continue;
|
|
1216
|
+
const [name, upstream = ""] = line.split(" ");
|
|
1217
|
+
if (!name || !TICKET_BRANCH.test(name)) continue;
|
|
1218
|
+
const seqMatch = name.match(/^task\/(\d+)/);
|
|
1219
|
+
out.push({
|
|
1220
|
+
name,
|
|
1221
|
+
sequenceNumber: seqMatch && seqMatch[1] ? parseInt(seqMatch[1], 10) : null,
|
|
1222
|
+
hasUpstream: upstream.trim().length > 0
|
|
1223
|
+
});
|
|
1224
|
+
}
|
|
1225
|
+
return out;
|
|
1226
|
+
}
|
|
1227
|
+
function branchSlug(sequenceNumber, title) {
|
|
1228
|
+
const safeTitle = title.toLowerCase().normalize("NFKD").replace(/[̀-ͯ]/g, "").replace(/[^a-z0-9\s-]/g, "").trim().replace(/\s+/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "");
|
|
1229
|
+
const slugBudget = 70;
|
|
1230
|
+
const truncatedSlug = safeTitle.slice(0, slugBudget);
|
|
1231
|
+
const tail = truncatedSlug.length > 0 ? `-${truncatedSlug}` : "";
|
|
1232
|
+
return `task/${sequenceNumber}${tail}`.replace(/-+$/, "");
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
// src/commands/status.ts
|
|
1002
1236
|
function registerStatus(program2) {
|
|
1003
|
-
program2.command("status").description("Show CLI auth, link, and
|
|
1237
|
+
program2.command("status").alias("s").description("Show CLI auth, link, git state, in-flight tickets, and orphan branches").option("--remote", "Also fetch /cli/access for live state").action(async (_opts) => {
|
|
1004
1238
|
const creds = await readCredentials();
|
|
1005
1239
|
const root = findRepoRoot();
|
|
1006
1240
|
const project = await readProjectConfig(root);
|
|
@@ -1036,12 +1270,13 @@ ${c.bold("Project link")}
|
|
|
1036
1270
|
process.stdout.write(`
|
|
1037
1271
|
${c.bold("Repo")}
|
|
1038
1272
|
`);
|
|
1273
|
+
let inGitRepo = false;
|
|
1039
1274
|
try {
|
|
1040
|
-
const branch =
|
|
1275
|
+
const branch = execFileSync3("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
|
|
1041
1276
|
cwd: root,
|
|
1042
1277
|
encoding: "utf8"
|
|
1043
1278
|
}).trim();
|
|
1044
|
-
const dirty =
|
|
1279
|
+
const dirty = execFileSync3("git", ["status", "--porcelain"], {
|
|
1045
1280
|
cwd: root,
|
|
1046
1281
|
encoding: "utf8"
|
|
1047
1282
|
});
|
|
@@ -1051,10 +1286,58 @@ ${c.bold("Repo")}
|
|
|
1051
1286
|
` ${dirty.trim().length > 0 ? c.warn("working tree dirty") : c.ok("clean")}
|
|
1052
1287
|
`
|
|
1053
1288
|
);
|
|
1289
|
+
inGitRepo = true;
|
|
1054
1290
|
} catch {
|
|
1055
1291
|
process.stdout.write(` ${c.warn("!")} not inside a git repo
|
|
1056
1292
|
`);
|
|
1057
1293
|
}
|
|
1294
|
+
if (creds && project && inGitRepo) {
|
|
1295
|
+
const inFlight = await apiCall("GET", "/api/v1/cli/me/tickets", {
|
|
1296
|
+
query: {
|
|
1297
|
+
project_id: project.project_id,
|
|
1298
|
+
ai_fix_status: "building",
|
|
1299
|
+
limit: 100
|
|
1300
|
+
}
|
|
1301
|
+
});
|
|
1302
|
+
const inFlightTickets = inFlight.ok && inFlight.data ? inFlight.data : [];
|
|
1303
|
+
const localBranches = listLocalTicketBranches(root);
|
|
1304
|
+
const inFlightBranchNames = new Set(
|
|
1305
|
+
inFlightTickets.map((t) => branchSlug(t.sequence_number, t.title))
|
|
1306
|
+
);
|
|
1307
|
+
process.stdout.write(`
|
|
1308
|
+
${c.bold("In-flight tickets")}
|
|
1309
|
+
`);
|
|
1310
|
+
if (inFlightTickets.length === 0) {
|
|
1311
|
+
process.stdout.write(c.dim(" none\n"));
|
|
1312
|
+
} else {
|
|
1313
|
+
for (const t of inFlightTickets) {
|
|
1314
|
+
const expectedBranch = branchSlug(t.sequence_number, t.title);
|
|
1315
|
+
const branchPresent = localBranches.some((b) => b.name === expectedBranch);
|
|
1316
|
+
const tag = branchPresent ? c.ok("local branch present") : c.warn("local branch missing");
|
|
1317
|
+
process.stdout.write(
|
|
1318
|
+
` ${c.dim("\xB7")} #${t.sequence_number} "${t.title}" \u2014 ${tag} \u2192 ${c.cyan(`task resume #${t.sequence_number}`)}
|
|
1319
|
+
`
|
|
1320
|
+
);
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1323
|
+
const orphans = localBranches.filter((b) => !inFlightBranchNames.has(b.name));
|
|
1324
|
+
process.stdout.write(`
|
|
1325
|
+
${c.bold("Orphan task/* branches")}
|
|
1326
|
+
`);
|
|
1327
|
+
if (orphans.length === 0) {
|
|
1328
|
+
process.stdout.write(c.dim(" none\n"));
|
|
1329
|
+
} else {
|
|
1330
|
+
for (const b of orphans) {
|
|
1331
|
+
const upstream = b.hasUpstream ? c.dim(" (has upstream)") : c.dim(" (no upstream)");
|
|
1332
|
+
process.stdout.write(` ${c.dim("\xB7")} ${b.name}${upstream}
|
|
1333
|
+
`);
|
|
1334
|
+
}
|
|
1335
|
+
process.stdout.write(
|
|
1336
|
+
` ${c.dim("\u2192 run")} ${c.cyan("task reset")} ${c.dim("to clean up")}
|
|
1337
|
+
`
|
|
1338
|
+
);
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1058
1341
|
});
|
|
1059
1342
|
}
|
|
1060
1343
|
|
|
@@ -1281,264 +1564,83 @@ async function runAgent(args) {
|
|
|
1281
1564
|
child.on("close", (code) => {
|
|
1282
1565
|
logHandle?.end();
|
|
1283
1566
|
const exitCode = code ?? 0;
|
|
1284
|
-
resolve2({ exitCode, ok: exitCode === 0, outputLogPath, stderrTail: stderrBuffer });
|
|
1285
|
-
});
|
|
1286
|
-
});
|
|
1287
|
-
}
|
|
1288
|
-
|
|
1289
|
-
// src/guardrail/diff-check.ts
|
|
1290
|
-
import { execFileSync as execFileSync2 } from "child_process";
|
|
1291
|
-
|
|
1292
|
-
// src/guardrail/protected-paths.ts
|
|
1293
|
-
import picomatch from "picomatch";
|
|
1294
|
-
function buildProtectedMatcher(projectExtensions = []) {
|
|
1295
|
-
const merged = Array.from(
|
|
1296
|
-
/* @__PURE__ */ new Set([
|
|
1297
|
-
...CLI_DEFAULT_PROTECTED_PATHS,
|
|
1298
|
-
...projectExtensions.map((p) => p.trim()).filter(Boolean)
|
|
1299
|
-
])
|
|
1300
|
-
);
|
|
1301
|
-
const matcher = picomatch(merged, {
|
|
1302
|
-
dot: true,
|
|
1303
|
-
nocase: false
|
|
1304
|
-
});
|
|
1305
|
-
function normalise(p) {
|
|
1306
|
-
return p.replace(/\\/g, "/");
|
|
1307
|
-
}
|
|
1308
|
-
return {
|
|
1309
|
-
patterns: merged,
|
|
1310
|
-
isProtected(path) {
|
|
1311
|
-
return matcher(normalise(path));
|
|
1312
|
-
},
|
|
1313
|
-
matchAll(paths) {
|
|
1314
|
-
const offending = [];
|
|
1315
|
-
for (const p of paths) {
|
|
1316
|
-
if (matcher(normalise(p))) offending.push(p);
|
|
1317
|
-
}
|
|
1318
|
-
return offending;
|
|
1319
|
-
}
|
|
1320
|
-
};
|
|
1321
|
-
}
|
|
1322
|
-
|
|
1323
|
-
// src/guardrail/diff-check.ts
|
|
1324
|
-
function checkDiff(args) {
|
|
1325
|
-
const matcher = buildProtectedMatcher(args.projectProtectedPaths);
|
|
1326
|
-
const stagedRaw = safeGitOutput(["diff", "--cached", "--name-only"], args.cwd);
|
|
1327
|
-
const unstagedRaw = safeGitOutput(["diff", "--name-only"], args.cwd);
|
|
1328
|
-
const untrackedRaw = safeGitOutput(["ls-files", "--others", "--exclude-standard"], args.cwd);
|
|
1329
|
-
const allChanged = Array.from(
|
|
1330
|
-
new Set(
|
|
1331
|
-
[...splitLines(stagedRaw), ...splitLines(unstagedRaw), ...splitLines(untrackedRaw)].filter(
|
|
1332
|
-
(l) => l.length > 0
|
|
1333
|
-
)
|
|
1334
|
-
)
|
|
1335
|
-
);
|
|
1336
|
-
const offending = matcher.matchAll(allChanged);
|
|
1337
|
-
if (offending.length === 0) {
|
|
1338
|
-
return { violation: false, changedPaths: allChanged, allowedPaths: allChanged };
|
|
1339
|
-
}
|
|
1340
|
-
return {
|
|
1341
|
-
violation: true,
|
|
1342
|
-
offendingPaths: offending,
|
|
1343
|
-
changedPaths: allChanged,
|
|
1344
|
-
patterns: matcher.patterns
|
|
1345
|
-
};
|
|
1346
|
-
}
|
|
1347
|
-
function safeGitOutput(args, cwd) {
|
|
1348
|
-
try {
|
|
1349
|
-
return execFileSync2("git", args, { cwd, encoding: "utf8" });
|
|
1350
|
-
} catch {
|
|
1351
|
-
return "";
|
|
1352
|
-
}
|
|
1353
|
-
}
|
|
1354
|
-
function splitLines(text) {
|
|
1355
|
-
return text.split(/\r?\n/).map((l) => l.trim()).filter((l) => l.length > 0);
|
|
1356
|
-
}
|
|
1357
|
-
|
|
1358
|
-
// src/git/commit.ts
|
|
1359
|
-
import { execFileSync as execFileSync3 } from "child_process";
|
|
1360
|
-
function commitOnly(args) {
|
|
1361
|
-
execFileSync3("git", ["add", "-A"], { cwd: args.cwd });
|
|
1362
|
-
const statusRaw = execFileSync3("git", ["status", "--porcelain"], {
|
|
1363
|
-
cwd: args.cwd,
|
|
1364
|
-
encoding: "utf8"
|
|
1365
|
-
});
|
|
1366
|
-
if (!statusRaw.trim()) {
|
|
1367
|
-
throw new Error("No changes to commit (empty diff)");
|
|
1368
|
-
}
|
|
1369
|
-
execFileSync3("git", ["commit", "-m", args.message], { cwd: args.cwd });
|
|
1370
|
-
const sha = execFileSync3("git", ["rev-parse", "HEAD"], {
|
|
1371
|
-
cwd: args.cwd,
|
|
1372
|
-
encoding: "utf8"
|
|
1373
|
-
}).trim();
|
|
1374
|
-
return { sha };
|
|
1375
|
-
}
|
|
1376
|
-
function currentBranch(cwd) {
|
|
1377
|
-
try {
|
|
1378
|
-
return execFileSync3("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
|
|
1379
|
-
cwd,
|
|
1380
|
-
encoding: "utf8"
|
|
1381
|
-
}).trim();
|
|
1382
|
-
} catch {
|
|
1383
|
-
return "HEAD";
|
|
1384
|
-
}
|
|
1385
|
-
}
|
|
1386
|
-
|
|
1387
|
-
// src/commands/work.ts
|
|
1388
|
-
import { execFileSync as execFileSync6 } from "child_process";
|
|
1389
|
-
|
|
1390
|
-
// src/git/branch.ts
|
|
1391
|
-
import { execFileSync as execFileSync4 } from "child_process";
|
|
1392
|
-
var VALID_BRANCH = /^[A-Za-z0-9._/-]{1,200}$/;
|
|
1393
|
-
var TICKET_BRANCH = /^task\/[a-z0-9-]{1,80}$/;
|
|
1394
|
-
function assertValidBranchName(branch) {
|
|
1395
|
-
if (!VALID_BRANCH.test(branch) || branch.includes("..") || branch.startsWith("/") || branch.endsWith("/")) {
|
|
1396
|
-
throw new CliError(
|
|
1397
|
-
CLI_EXIT_CODES.MISCONFIGURATION,
|
|
1398
|
-
`Invalid branch name: ${branch}`,
|
|
1399
|
-
'Branch names must contain only [A-Za-z0-9._/-], no "..", and no leading/trailing slash.'
|
|
1400
|
-
);
|
|
1401
|
-
}
|
|
1402
|
-
}
|
|
1403
|
-
function isWorkingTreeClean(cwd) {
|
|
1404
|
-
const out = execFileSync4("git", ["status", "--porcelain"], {
|
|
1405
|
-
cwd,
|
|
1406
|
-
encoding: "utf8"
|
|
1407
|
-
});
|
|
1408
|
-
return out.trim().length === 0;
|
|
1409
|
-
}
|
|
1410
|
-
function assertBaseBranch(cwd, expected) {
|
|
1411
|
-
assertValidBranchName(expected);
|
|
1412
|
-
const current = currentBranch(cwd);
|
|
1413
|
-
if (current !== expected) {
|
|
1414
|
-
throw new CliError(
|
|
1415
|
-
CLI_EXIT_CODES.MISCONFIGURATION,
|
|
1416
|
-
`task work requires branch "${expected}" but you're on "${current}"`,
|
|
1417
|
-
`Run "git checkout ${expected}" first. The base branch is configured per project; ask an admin if it should be different.`
|
|
1418
|
-
);
|
|
1419
|
-
}
|
|
1420
|
-
if (!isWorkingTreeClean(cwd)) {
|
|
1421
|
-
throw new CliError(
|
|
1422
|
-
CLI_EXIT_CODES.MISCONFIGURATION,
|
|
1423
|
-
"Working tree is dirty",
|
|
1424
|
-
"Commit, stash, or discard your local changes before running task work."
|
|
1425
|
-
);
|
|
1426
|
-
}
|
|
1427
|
-
}
|
|
1428
|
-
function createTicketBranch(cwd, branchName, baseBranch) {
|
|
1429
|
-
assertValidBranchName(branchName);
|
|
1430
|
-
assertValidBranchName(baseBranch);
|
|
1431
|
-
if (!TICKET_BRANCH.test(branchName)) {
|
|
1432
|
-
throw new CliError(
|
|
1433
|
-
CLI_EXIT_CODES.MISCONFIGURATION,
|
|
1434
|
-
`Per-ticket branch must match ^task/[a-z0-9-]{1,80}$ \u2014 got "${branchName}"`
|
|
1435
|
-
);
|
|
1436
|
-
}
|
|
1437
|
-
try {
|
|
1438
|
-
execFileSync4("git", ["checkout", "-b", branchName, baseBranch], {
|
|
1439
|
-
cwd,
|
|
1440
|
-
stdio: ["ignore", "pipe", "pipe"]
|
|
1441
|
-
});
|
|
1442
|
-
} catch (err) {
|
|
1443
|
-
const stderr = err.stderr?.toString("utf8") ?? "";
|
|
1444
|
-
throw new CliError(
|
|
1445
|
-
CLI_EXIT_CODES.GENERIC_ERROR,
|
|
1446
|
-
`Could not create branch ${branchName}: ${stderr.slice(0, 400) || err.message}`
|
|
1447
|
-
);
|
|
1448
|
-
}
|
|
1449
|
-
}
|
|
1450
|
-
function deleteLocalBranch(cwd, branchName) {
|
|
1451
|
-
if (!VALID_BRANCH.test(branchName)) return;
|
|
1452
|
-
try {
|
|
1453
|
-
execFileSync4("git", ["branch", "-D", branchName], {
|
|
1454
|
-
cwd,
|
|
1455
|
-
stdio: ["ignore", "ignore", "ignore"]
|
|
1456
|
-
});
|
|
1457
|
-
} catch {
|
|
1458
|
-
}
|
|
1459
|
-
}
|
|
1460
|
-
function checkoutBranch(cwd, branchName) {
|
|
1461
|
-
assertValidBranchName(branchName);
|
|
1462
|
-
try {
|
|
1463
|
-
execFileSync4("git", ["checkout", branchName], {
|
|
1464
|
-
cwd,
|
|
1465
|
-
stdio: ["ignore", "pipe", "pipe"]
|
|
1466
|
-
});
|
|
1467
|
-
} catch (err) {
|
|
1468
|
-
const stderr = err.stderr?.toString("utf8") ?? "";
|
|
1469
|
-
throw new CliError(
|
|
1470
|
-
CLI_EXIT_CODES.GENERIC_ERROR,
|
|
1471
|
-
`Could not check out branch ${branchName}: ${stderr.slice(0, 400) || err.message}`
|
|
1472
|
-
);
|
|
1473
|
-
}
|
|
1474
|
-
}
|
|
1475
|
-
function pushBranch(cwd, branchName) {
|
|
1476
|
-
assertValidBranchName(branchName);
|
|
1477
|
-
try {
|
|
1478
|
-
execFileSync4("git", ["push", "-u", "origin", branchName], {
|
|
1479
|
-
cwd,
|
|
1480
|
-
stdio: ["ignore", "pipe", "pipe"]
|
|
1567
|
+
resolve2({ exitCode, ok: exitCode === 0, outputLogPath, stderrTail: stderrBuffer });
|
|
1481
1568
|
});
|
|
1482
|
-
|
|
1483
|
-
} catch (err) {
|
|
1484
|
-
const stderr = err.stderr?.toString("utf8") ?? "";
|
|
1485
|
-
throw new CliError(
|
|
1486
|
-
CLI_EXIT_CODES.GENERIC_ERROR,
|
|
1487
|
-
`Push failed: ${stderr.slice(0, 600) || err.message}`
|
|
1488
|
-
);
|
|
1489
|
-
}
|
|
1569
|
+
});
|
|
1490
1570
|
}
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1571
|
+
|
|
1572
|
+
// src/guardrail/diff-check.ts
|
|
1573
|
+
import { execFileSync as execFileSync4 } from "child_process";
|
|
1574
|
+
|
|
1575
|
+
// src/guardrail/protected-paths.ts
|
|
1576
|
+
import picomatch from "picomatch";
|
|
1577
|
+
function buildProtectedMatcher(projectExtensions = []) {
|
|
1578
|
+
const merged = Array.from(
|
|
1579
|
+
/* @__PURE__ */ new Set([
|
|
1580
|
+
...CLI_DEFAULT_PROTECTED_PATHS,
|
|
1581
|
+
...projectExtensions.map((p) => p.trim()).filter(Boolean)
|
|
1582
|
+
])
|
|
1583
|
+
);
|
|
1584
|
+
const matcher = picomatch(merged, {
|
|
1585
|
+
dot: true,
|
|
1586
|
+
nocase: false
|
|
1587
|
+
});
|
|
1588
|
+
function normalise(p) {
|
|
1589
|
+
return p.replace(/\\/g, "/");
|
|
1590
|
+
}
|
|
1591
|
+
return {
|
|
1592
|
+
patterns: merged,
|
|
1593
|
+
isProtected(path) {
|
|
1594
|
+
return matcher(normalise(path));
|
|
1595
|
+
},
|
|
1596
|
+
matchAll(paths) {
|
|
1597
|
+
const offending = [];
|
|
1598
|
+
for (const p of paths) {
|
|
1599
|
+
if (matcher(normalise(p))) offending.push(p);
|
|
1600
|
+
}
|
|
1601
|
+
return offending;
|
|
1505
1602
|
}
|
|
1603
|
+
};
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
// src/guardrail/diff-check.ts
|
|
1607
|
+
function checkDiff(args) {
|
|
1608
|
+
const matcher = buildProtectedMatcher(args.projectProtectedPaths);
|
|
1609
|
+
const stagedRaw = safeGitOutput(["diff", "--cached", "--name-only"], args.cwd);
|
|
1610
|
+
const unstagedRaw = safeGitOutput(["diff", "--name-only"], args.cwd);
|
|
1611
|
+
const untrackedRaw = safeGitOutput(["ls-files", "--others", "--exclude-standard"], args.cwd);
|
|
1612
|
+
const allChanged = Array.from(
|
|
1613
|
+
new Set(
|
|
1614
|
+
[...splitLines(stagedRaw), ...splitLines(unstagedRaw), ...splitLines(untrackedRaw)].filter(
|
|
1615
|
+
(l) => l.length > 0
|
|
1616
|
+
)
|
|
1617
|
+
)
|
|
1618
|
+
);
|
|
1619
|
+
const offending = matcher.matchAll(allChanged);
|
|
1620
|
+
if (offending.length === 0) {
|
|
1621
|
+
return { violation: false, changedPaths: allChanged, allowedPaths: allChanged };
|
|
1506
1622
|
}
|
|
1623
|
+
return {
|
|
1624
|
+
violation: true,
|
|
1625
|
+
offendingPaths: offending,
|
|
1626
|
+
changedPaths: allChanged,
|
|
1627
|
+
patterns: matcher.patterns
|
|
1628
|
+
};
|
|
1629
|
+
}
|
|
1630
|
+
function safeGitOutput(args, cwd) {
|
|
1507
1631
|
try {
|
|
1508
|
-
execFileSync4("git",
|
|
1509
|
-
cwd,
|
|
1510
|
-
stdio: ["ignore", "pipe", "pipe"]
|
|
1511
|
-
});
|
|
1632
|
+
return execFileSync4("git", args, { cwd, encoding: "utf8" });
|
|
1512
1633
|
} catch {
|
|
1513
|
-
|
|
1514
|
-
checkoutBranch(cwd, baseBranch);
|
|
1515
|
-
const current = currentBranch(cwd);
|
|
1516
|
-
if (current !== baseBranch) {
|
|
1517
|
-
throw new CliError(
|
|
1518
|
-
CLI_EXIT_CODES.GENERIC_ERROR,
|
|
1519
|
-
`Branch enforcement failed: expected "${baseBranch}", on "${current}"`,
|
|
1520
|
-
`Run "git checkout ${baseBranch}" manually, then re-run task multi-work.`
|
|
1521
|
-
);
|
|
1522
|
-
}
|
|
1523
|
-
if (!isWorkingTreeClean(cwd)) {
|
|
1524
|
-
throw new CliError(
|
|
1525
|
-
CLI_EXIT_CODES.GENERIC_ERROR,
|
|
1526
|
-
"Branch enforcement failed: working tree is dirty after returning to base",
|
|
1527
|
-
'Inspect with "git status" and clean up before re-running task multi-work.'
|
|
1528
|
-
);
|
|
1529
|
-
}
|
|
1530
|
-
if (opts.deleteBranch && opts.deleteBranch !== baseBranch) {
|
|
1531
|
-
deleteLocalBranch(cwd, opts.deleteBranch);
|
|
1634
|
+
return "";
|
|
1532
1635
|
}
|
|
1533
1636
|
}
|
|
1534
|
-
function
|
|
1535
|
-
|
|
1536
|
-
const slugBudget = 70;
|
|
1537
|
-
const truncatedSlug = safeTitle.slice(0, slugBudget);
|
|
1538
|
-
const tail = truncatedSlug.length > 0 ? `-${truncatedSlug}` : "";
|
|
1539
|
-
return `task/${sequenceNumber}${tail}`.replace(/-+$/, "");
|
|
1637
|
+
function splitLines(text) {
|
|
1638
|
+
return text.split(/\r?\n/).map((l) => l.trim()).filter((l) => l.length > 0);
|
|
1540
1639
|
}
|
|
1541
1640
|
|
|
1641
|
+
// src/commands/work.ts
|
|
1642
|
+
import { execFileSync as execFileSync6 } from "child_process";
|
|
1643
|
+
|
|
1542
1644
|
// src/git/restore.ts
|
|
1543
1645
|
import { execFileSync as execFileSync5 } from "child_process";
|
|
1544
1646
|
function discardWorkingTreeChanges(cwd) {
|
|
@@ -1717,15 +1819,36 @@ async function processOneTicket(ctx, opts, ticketIdHint) {
|
|
|
1717
1819
|
}
|
|
1718
1820
|
const branchName = branchSlug(detail.sequence_number, detail.title);
|
|
1719
1821
|
const testCommand = detail.project_cli_test_command ?? null;
|
|
1822
|
+
const ticketBaseBranch = detail.project_cli_base_branch || baseBranch;
|
|
1720
1823
|
if (!silent) {
|
|
1721
1824
|
process.stdout.write(`
|
|
1722
1825
|
${c.bold(`#${detail.sequence_number}: ${detail.title}`)}
|
|
1723
1826
|
`);
|
|
1724
|
-
process.stdout.write(c.dim(` base branch: ${
|
|
1827
|
+
process.stdout.write(c.dim(` base branch: ${ticketBaseBranch}
|
|
1725
1828
|
`));
|
|
1726
1829
|
process.stdout.write(c.dim(` ticket branch: ${branchName}
|
|
1727
1830
|
`));
|
|
1728
1831
|
}
|
|
1832
|
+
try {
|
|
1833
|
+
if (!remoteBranchExists(cwd, ticketBaseBranch)) {
|
|
1834
|
+
await apiCall("POST", "/api/v1/cli/me/runs", {
|
|
1835
|
+
body: {
|
|
1836
|
+
ticket_id: detail.id,
|
|
1837
|
+
schedule_id: opts.scheduleId,
|
|
1838
|
+
event: "branch_check_failed",
|
|
1839
|
+
output_excerpt: `base branch "${ticketBaseBranch}" missing on origin`
|
|
1840
|
+
}
|
|
1841
|
+
});
|
|
1842
|
+
throw new CliError(
|
|
1843
|
+
CLI_EXIT_CODES.MISCONFIGURATION,
|
|
1844
|
+
`Base branch "${ticketBaseBranch}" does not exist on origin`,
|
|
1845
|
+
`Push it (\`git push origin ${ticketBaseBranch}\`) or update the project's cli_base_branch via the dashboard / PATCH /api/v1/projects/<id>.`
|
|
1846
|
+
);
|
|
1847
|
+
}
|
|
1848
|
+
} catch (err) {
|
|
1849
|
+
if (err instanceof CliError) throw err;
|
|
1850
|
+
throw err;
|
|
1851
|
+
}
|
|
1729
1852
|
const runId = randomUUID();
|
|
1730
1853
|
await apiCall("POST", "/api/v1/cli/me/runs", {
|
|
1731
1854
|
body: {
|
|
@@ -1736,18 +1859,44 @@ ${c.bold(`#${detail.sequence_number}: ${detail.title}`)}
|
|
|
1736
1859
|
}
|
|
1737
1860
|
});
|
|
1738
1861
|
try {
|
|
1739
|
-
createTicketBranch(cwd, branchName,
|
|
1862
|
+
createTicketBranch(cwd, branchName, ticketBaseBranch);
|
|
1740
1863
|
} catch (err) {
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1864
|
+
const stderr = err instanceof Error ? err.message : String(err);
|
|
1865
|
+
const looksLikeAlreadyExists = /already exists/i.test(stderr);
|
|
1866
|
+
if (looksLikeAlreadyExists) {
|
|
1867
|
+
const recovered = await tryAutoCleanOrphanBranch(
|
|
1868
|
+
ctx,
|
|
1869
|
+
detail,
|
|
1870
|
+
branchName,
|
|
1871
|
+
ticketBaseBranch,
|
|
1872
|
+
runId,
|
|
1873
|
+
opts.scheduleId
|
|
1874
|
+
);
|
|
1875
|
+
if (recovered === "recreated") {
|
|
1876
|
+
} else {
|
|
1877
|
+
await apiCall("POST", "/api/v1/cli/me/runs", {
|
|
1878
|
+
body: {
|
|
1879
|
+
ticket_id: detail.id,
|
|
1880
|
+
schedule_id: opts.scheduleId,
|
|
1881
|
+
event: "branch_check_failed",
|
|
1882
|
+
claude_session_id: runId,
|
|
1883
|
+
output_excerpt: stderr.slice(0, 4e3)
|
|
1884
|
+
}
|
|
1885
|
+
});
|
|
1886
|
+
throw err;
|
|
1748
1887
|
}
|
|
1749
|
-
}
|
|
1750
|
-
|
|
1888
|
+
} else {
|
|
1889
|
+
await apiCall("POST", "/api/v1/cli/me/runs", {
|
|
1890
|
+
body: {
|
|
1891
|
+
ticket_id: detail.id,
|
|
1892
|
+
schedule_id: opts.scheduleId,
|
|
1893
|
+
event: "branch_check_failed",
|
|
1894
|
+
claude_session_id: runId,
|
|
1895
|
+
output_excerpt: stderr.slice(0, 4e3)
|
|
1896
|
+
}
|
|
1897
|
+
});
|
|
1898
|
+
throw err;
|
|
1899
|
+
}
|
|
1751
1900
|
}
|
|
1752
1901
|
await apiCall("POST", "/api/v1/cli/me/runs", {
|
|
1753
1902
|
body: {
|
|
@@ -2007,14 +2156,21 @@ Claude session: ${runId}
|
|
|
2007
2156
|
process.stdout.write(`${c.ok("\u2713 Pushed")} ${branchName} (${commitSha.slice(0, 12)})
|
|
2008
2157
|
`);
|
|
2009
2158
|
const prTitle = `task #${detail.sequence_number}: ${detail.title}`.slice(0, 200);
|
|
2010
|
-
const prBody = buildPrBody({
|
|
2159
|
+
const prBody = buildPrBody({
|
|
2160
|
+
detail,
|
|
2161
|
+
runId,
|
|
2162
|
+
commitSha,
|
|
2163
|
+
branchName,
|
|
2164
|
+
baseBranch: ticketBaseBranch,
|
|
2165
|
+
testResult
|
|
2166
|
+
});
|
|
2011
2167
|
let prNumber;
|
|
2012
2168
|
let prUrl;
|
|
2013
2169
|
try {
|
|
2014
2170
|
const prResp = await apiCallOrThrow("POST", `/api/v1/cli/me/tickets/${detail.id}/pull-requests`, {
|
|
2015
2171
|
body: {
|
|
2016
2172
|
source_branch: branchName,
|
|
2017
|
-
base_branch:
|
|
2173
|
+
base_branch: ticketBaseBranch,
|
|
2018
2174
|
title: prTitle,
|
|
2019
2175
|
body: prBody
|
|
2020
2176
|
}
|
|
@@ -2032,7 +2188,7 @@ Claude session: ${runId}
|
|
|
2032
2188
|
});
|
|
2033
2189
|
if (!silent) {
|
|
2034
2190
|
process.stdout.write(
|
|
2035
|
-
`${c.ok("\u2713 PR opened")} ${c.cyan(prResp.pr_url)} \u2192 ${
|
|
2191
|
+
`${c.ok("\u2713 PR opened")} ${c.cyan(prResp.pr_url)} \u2192 ${ticketBaseBranch}
|
|
2036
2192
|
` + (prResp.ticket_status_advanced ? c.dim(` Ticket status auto-advanced to 'git_review'.
|
|
2037
2193
|
`) : "")
|
|
2038
2194
|
);
|
|
@@ -2160,6 +2316,54 @@ ${c.bold("--reset will:")}
|
|
|
2160
2316
|
}
|
|
2161
2317
|
enforceBaseBranchClean(cwd, baseBranch);
|
|
2162
2318
|
}
|
|
2319
|
+
async function tryAutoCleanOrphanBranch(ctx, detail, branchName, ticketBaseBranch, runId, scheduleId) {
|
|
2320
|
+
const inFlight = await apiCall(
|
|
2321
|
+
"GET",
|
|
2322
|
+
"/api/v1/cli/me/tickets",
|
|
2323
|
+
{
|
|
2324
|
+
query: {
|
|
2325
|
+
project_id: ctx.project.project_id,
|
|
2326
|
+
ai_fix_status: "building",
|
|
2327
|
+
limit: 100
|
|
2328
|
+
}
|
|
2329
|
+
}
|
|
2330
|
+
);
|
|
2331
|
+
const claimedByMe = inFlight.ok && inFlight.data ? inFlight.data.some((t) => t.sequence_number === detail.sequence_number) : false;
|
|
2332
|
+
if (claimedByMe) {
|
|
2333
|
+
if (!ctx.silent) {
|
|
2334
|
+
process.stderr.write(
|
|
2335
|
+
`${c.dim(" Local branch matches an in-flight ticket; refusing to auto-delete.")}
|
|
2336
|
+
${c.dim(` Run \`task resume #${detail.sequence_number}\` to retry the PR for the existing branch.`)}
|
|
2337
|
+
`
|
|
2338
|
+
);
|
|
2339
|
+
}
|
|
2340
|
+
return "in_flight";
|
|
2341
|
+
}
|
|
2342
|
+
try {
|
|
2343
|
+
deleteLocalBranch(ctx.cwd, branchName);
|
|
2344
|
+
createTicketBranch(ctx.cwd, branchName, ticketBaseBranch);
|
|
2345
|
+
} catch {
|
|
2346
|
+
return "failed";
|
|
2347
|
+
}
|
|
2348
|
+
await apiCall("POST", "/api/v1/cli/me/runs", {
|
|
2349
|
+
body: {
|
|
2350
|
+
ticket_id: detail.id,
|
|
2351
|
+
schedule_id: scheduleId,
|
|
2352
|
+
event: "branch_check_failed",
|
|
2353
|
+
claude_session_id: runId,
|
|
2354
|
+
output_excerpt: "auto-cleaned-orphan-and-recreated"
|
|
2355
|
+
}
|
|
2356
|
+
});
|
|
2357
|
+
if (!ctx.silent) {
|
|
2358
|
+
process.stdout.write(
|
|
2359
|
+
c.dim(
|
|
2360
|
+
` Auto-cleaned orphan local branch '${branchName}' and re-cut from ${ticketBaseBranch}.
|
|
2361
|
+
`
|
|
2362
|
+
)
|
|
2363
|
+
);
|
|
2364
|
+
}
|
|
2365
|
+
return "recreated";
|
|
2366
|
+
}
|
|
2163
2367
|
async function pickNextEligible(projectId) {
|
|
2164
2368
|
const result = await apiCall("GET", "/api/v1/cli/me/tickets", {
|
|
2165
2369
|
query: { project_id: projectId, limit: 1 }
|
|
@@ -2586,6 +2790,114 @@ function buildResumePrBody(detail, branchName, baseBranch) {
|
|
|
2586
2790
|
].filter(Boolean).join("\n");
|
|
2587
2791
|
}
|
|
2588
2792
|
|
|
2793
|
+
// src/commands/reset.ts
|
|
2794
|
+
import inquirer4 from "inquirer";
|
|
2795
|
+
function registerReset(program2) {
|
|
2796
|
+
program2.command("reset").alias("r").description(
|
|
2797
|
+
"Delete orphan local task/* branches \u2014 branches the CLI created locally but that are no longer in your in-flight (`ai_fix_status='building'`) list. Interactive y/n in TTY; pair with --confirm in non-TTY contexts."
|
|
2798
|
+
).option("--silent", "Suppress TTY output").option("--confirm", "Confirm deletion in non-TTY (silent / scheduled-task) contexts").action(async (opts) => {
|
|
2799
|
+
await runReset(opts);
|
|
2800
|
+
});
|
|
2801
|
+
}
|
|
2802
|
+
async function runReset(opts) {
|
|
2803
|
+
const cwd = findRepoRoot();
|
|
2804
|
+
const project = await readProjectConfig(cwd);
|
|
2805
|
+
if (!project) {
|
|
2806
|
+
throw new CliError(
|
|
2807
|
+
CLI_EXIT_CODES.MISCONFIGURATION,
|
|
2808
|
+
"No project link in this repo",
|
|
2809
|
+
"Run 'task link' first."
|
|
2810
|
+
);
|
|
2811
|
+
}
|
|
2812
|
+
const silent = !!opts.silent;
|
|
2813
|
+
const localBranches = listLocalTicketBranches(cwd);
|
|
2814
|
+
if (localBranches.length === 0) {
|
|
2815
|
+
if (!silent) process.stdout.write(c.dim("No local task/* branches \u2014 nothing to clean up.\n"));
|
|
2816
|
+
return;
|
|
2817
|
+
}
|
|
2818
|
+
const inFlight = await apiCall("GET", "/api/v1/cli/me/tickets", {
|
|
2819
|
+
query: { project_id: project.project_id, ai_fix_status: "building", limit: 100 }
|
|
2820
|
+
});
|
|
2821
|
+
const inFlightBranchNames = /* @__PURE__ */ new Set();
|
|
2822
|
+
if (inFlight.ok && inFlight.data) {
|
|
2823
|
+
for (const t of inFlight.data) {
|
|
2824
|
+
inFlightBranchNames.add(branchSlug(t.sequence_number, t.title));
|
|
2825
|
+
}
|
|
2826
|
+
}
|
|
2827
|
+
const orphans = localBranches.filter((b) => !inFlightBranchNames.has(b.name));
|
|
2828
|
+
const kept = localBranches.filter((b) => inFlightBranchNames.has(b.name));
|
|
2829
|
+
if (orphans.length === 0) {
|
|
2830
|
+
if (!silent) {
|
|
2831
|
+
process.stdout.write(c.dim("No orphan task/* branches.\n"));
|
|
2832
|
+
if (kept.length > 0) {
|
|
2833
|
+
process.stdout.write(
|
|
2834
|
+
c.dim(
|
|
2835
|
+
` ${kept.length} in-flight branch(es) preserved: ${kept.map((b) => b.name).join(", ")}
|
|
2836
|
+
`
|
|
2837
|
+
)
|
|
2838
|
+
);
|
|
2839
|
+
}
|
|
2840
|
+
}
|
|
2841
|
+
return;
|
|
2842
|
+
}
|
|
2843
|
+
if (!silent) {
|
|
2844
|
+
process.stdout.write(`${c.bold("Orphan task/* branches:")}
|
|
2845
|
+
`);
|
|
2846
|
+
for (const b of orphans) {
|
|
2847
|
+
const upstream = b.hasUpstream ? c.dim(" (has upstream)") : c.dim(" (no upstream)");
|
|
2848
|
+
process.stdout.write(` ${c.dim("\xB7")} ${b.name}${upstream}
|
|
2849
|
+
`);
|
|
2850
|
+
}
|
|
2851
|
+
if (kept.length > 0) {
|
|
2852
|
+
process.stdout.write(`
|
|
2853
|
+
${c.dim("In-flight branch(es) preserved (use `task resume`):")}
|
|
2854
|
+
`);
|
|
2855
|
+
for (const b of kept) {
|
|
2856
|
+
process.stdout.write(` ${c.dim("\xB7")} ${b.name}
|
|
2857
|
+
`);
|
|
2858
|
+
}
|
|
2859
|
+
}
|
|
2860
|
+
process.stdout.write("\n");
|
|
2861
|
+
}
|
|
2862
|
+
const isTty = !silent && process.stdin.isTTY === true;
|
|
2863
|
+
if (!isTty) {
|
|
2864
|
+
if (!opts.confirm) {
|
|
2865
|
+
throw new CliError(
|
|
2866
|
+
CLI_EXIT_CODES.MISCONFIGURATION,
|
|
2867
|
+
"`task reset` requires --confirm in non-interactive (silent / scheduled-task) contexts",
|
|
2868
|
+
"Re-run with --confirm only after verifying the orphan list is what you expect."
|
|
2869
|
+
);
|
|
2870
|
+
}
|
|
2871
|
+
} else {
|
|
2872
|
+
const answer = await inquirer4.prompt([
|
|
2873
|
+
{
|
|
2874
|
+
type: "confirm",
|
|
2875
|
+
name: "confirm",
|
|
2876
|
+
message: `Delete ${orphans.length} orphan branch(es)?`,
|
|
2877
|
+
default: false
|
|
2878
|
+
}
|
|
2879
|
+
]);
|
|
2880
|
+
if (!answer.confirm) {
|
|
2881
|
+
if (!silent) process.stdout.write(c.dim("Aborted \u2014 no branches deleted.\n"));
|
|
2882
|
+
process.exit(CLI_EXIT_CODES.SUCCESS);
|
|
2883
|
+
}
|
|
2884
|
+
}
|
|
2885
|
+
let deleted = 0;
|
|
2886
|
+
for (const b of orphans) {
|
|
2887
|
+
deleteLocalBranch(cwd, b.name);
|
|
2888
|
+
deleted += 1;
|
|
2889
|
+
if (!silent) process.stdout.write(`${c.ok("\u2713")} deleted ${b.name}
|
|
2890
|
+
`);
|
|
2891
|
+
}
|
|
2892
|
+
if (!silent) {
|
|
2893
|
+
process.stdout.write(
|
|
2894
|
+
`
|
|
2895
|
+
${c.bold(`Cleaned up ${deleted} branch(es).`)} ${c.dim("Run `task work` to start fresh.")}
|
|
2896
|
+
`
|
|
2897
|
+
);
|
|
2898
|
+
}
|
|
2899
|
+
}
|
|
2900
|
+
|
|
2589
2901
|
// src/commands/scan.ts
|
|
2590
2902
|
import { randomUUID as randomUUID2 } from "crypto";
|
|
2591
2903
|
import ora2 from "ora";
|
|
@@ -4462,6 +4774,25 @@ function registerDoctor(program2) {
|
|
|
4462
4774
|
detail: `${apiUrl}: ${err.message}`
|
|
4463
4775
|
});
|
|
4464
4776
|
}
|
|
4777
|
+
if (project) {
|
|
4778
|
+
const baseBranch = project.cli_base_branch ?? "development";
|
|
4779
|
+
try {
|
|
4780
|
+
const exists = remoteBranchExists(root, baseBranch);
|
|
4781
|
+
checks.push({
|
|
4782
|
+
name: "base branch on origin",
|
|
4783
|
+
ok: exists,
|
|
4784
|
+
detail: exists ? `origin/${baseBranch} reachable` : `origin has no branch "${baseBranch}"`,
|
|
4785
|
+
remediation: exists ? void 0 : `push it (\`git push origin ${baseBranch}\`) or update the project's cli_base_branch via PATCH /api/v1/projects/${project.project_id}`
|
|
4786
|
+
});
|
|
4787
|
+
} catch (err) {
|
|
4788
|
+
checks.push({
|
|
4789
|
+
name: "base branch on origin",
|
|
4790
|
+
ok: false,
|
|
4791
|
+
detail: err instanceof CliError ? err.message : `could not check origin: ${err.message}`,
|
|
4792
|
+
remediation: err instanceof CliError && err.hint ? err.hint : "Verify that `origin` is configured (`git remote -v`)."
|
|
4793
|
+
});
|
|
4794
|
+
}
|
|
4795
|
+
}
|
|
4465
4796
|
try {
|
|
4466
4797
|
const dirty = execFileSync12("git", ["status", "--porcelain"], {
|
|
4467
4798
|
cwd: root,
|
|
@@ -4613,11 +4944,12 @@ async function checkPrePushTest(root, configuredCommand, fix) {
|
|
|
4613
4944
|
function resolveScriptName(argv) {
|
|
4614
4945
|
const [exe, ...rest] = argv;
|
|
4615
4946
|
if (!exe || rest.length === 0) return null;
|
|
4947
|
+
const hasAnyFlag = rest.some((tok) => tok.startsWith("-"));
|
|
4948
|
+
if (hasAnyFlag) return null;
|
|
4616
4949
|
if (exe === "pnpm" || exe === "yarn" || exe === "bun") {
|
|
4617
4950
|
const next = rest[0];
|
|
4618
4951
|
if (!next) return null;
|
|
4619
4952
|
if (next === "run") return rest[1] ?? null;
|
|
4620
|
-
if (next.startsWith("-")) return null;
|
|
4621
4953
|
return next;
|
|
4622
4954
|
}
|
|
4623
4955
|
if (exe === "npm") {
|
|
@@ -4643,7 +4975,7 @@ function checkBinary(name, command) {
|
|
|
4643
4975
|
}
|
|
4644
4976
|
|
|
4645
4977
|
// src/commands/version.ts
|
|
4646
|
-
var CLI_VERSION = true ? "0.2.
|
|
4978
|
+
var CLI_VERSION = true ? "0.2.7" : "0.0.0-dev";
|
|
4647
4979
|
function registerVersion(program2) {
|
|
4648
4980
|
program2.command("version").description("Print the CLI version").action(() => {
|
|
4649
4981
|
process.stdout.write(CLI_VERSION + "\n");
|
|
@@ -4668,6 +5000,7 @@ registerTicket(program);
|
|
|
4668
5000
|
registerWork(program);
|
|
4669
5001
|
registerMultiWork(program);
|
|
4670
5002
|
registerResume(program);
|
|
5003
|
+
registerReset(program);
|
|
4671
5004
|
registerScan(program);
|
|
4672
5005
|
registerPrTest(program);
|
|
4673
5006
|
registerScheduledTask(program);
|