@goodtek/vibeops 2.4.4 → 2.5.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.
@@ -1,6 +1,7 @@
1
1
  import { resolve } from "node:path";
2
2
  import { pathExists, writeText } from "../lib/filesystem.js";
3
3
  import { GitConfigError, requireGitConfig } from "../lib/git-config.js";
4
+ import { ensureIntegrationSynced, printIntegrationSyncDiagnosis, } from "../lib/git-integration-sync.js";
4
5
  import { askInput } from "../lib/inquirer-helpers.js";
5
6
  import { bold, cyan, dim, log, yellow } from "../lib/logger.js";
6
7
  import { projectPaths } from "../lib/paths.js";
@@ -8,7 +9,7 @@ import { slugify } from "../lib/slug.js";
8
9
  import { fallbackTaskDraft, llmScaffoldTask } from "../lib/task-add-llm.js";
9
10
  import { allocateNextTaskNumber, formatTaskId, uniqueTaskPath, } from "../lib/task-scaffold.js";
10
11
  import { findBlockingTask, relPath } from "../lib/task-context.js";
11
- import { startTaskBranch } from "../lib/task-start.js";
12
+ import { isIncompleteTaskStart, startTaskBranch } from "../lib/task-start.js";
12
13
  import { loadActionableTasks, readGitContext } from "../lib/task.js";
13
14
  async function loadGitConfigOrNull(root, dryRun) {
14
15
  try {
@@ -25,6 +26,31 @@ async function loadGitConfigOrNull(root, dryRun) {
25
26
  throw e;
26
27
  }
27
28
  }
29
+ async function finishWithBranch(root, taskId, filePath, gitCfg, opts) {
30
+ const relFile = relPath(root, filePath);
31
+ log.blank();
32
+ log.step("Starting task branch…");
33
+ const started = await startTaskBranch({
34
+ cwd: root,
35
+ taskFile: filePath,
36
+ integrationBranch: gitCfg.integrationBranch,
37
+ remote: gitCfg.remote,
38
+ allowDirty: true,
39
+ dryRun: opts.dryRun,
40
+ skipIntegrationPull: opts.skipIntegrationPull,
41
+ });
42
+ if (!started) {
43
+ process.exitCode = 1;
44
+ return;
45
+ }
46
+ if (opts.dryRun)
47
+ return;
48
+ log.blank();
49
+ log.info(bold("Next in Cursor"));
50
+ log.info(` Ask: @${relFile} — plan Scope / Acceptance Criteria`);
51
+ log.info(` Agent: same file — implement`);
52
+ log.info(` Ship: ${cyan(`vibeops task ship ${taskId}`)}`);
53
+ }
28
54
  export async function taskAddCommand(opts = {}) {
29
55
  const root = resolve(opts.cwd ?? process.cwd());
30
56
  const dryRun = opts.dryRun === true;
@@ -36,7 +62,34 @@ export async function taskAddCommand(opts = {}) {
36
62
  return;
37
63
  const paths = projectPaths(root);
38
64
  const blocking = await findBlockingTask(paths, root);
39
- if (blocking !== null) {
65
+ // Resume: In Progress file exists but task branch / Git Context was never finished.
66
+ if (blocking !== null && gitCfg !== null) {
67
+ const incomplete = await isIncompleteTaskStart(root, blocking.filePath);
68
+ if (incomplete) {
69
+ log.warn(`${yellow("Incomplete")} — ${bold(blocking.id)} exists but the task branch was not created.`);
70
+ log.info(` ${dim("file")} ${relPath(root, blocking.filePath)}`);
71
+ log.info(dim("Resuming branch setup (will not create a new TASK)…"));
72
+ if (!dryRun) {
73
+ const synced = await ensureIntegrationSynced({
74
+ cwd: root,
75
+ remote: gitCfg.remote,
76
+ integrationBranch: gitCfg.integrationBranch,
77
+ fetch: true,
78
+ });
79
+ if (!synced.ok) {
80
+ printIntegrationSyncDiagnosis(synced.diagnosis);
81
+ process.exitCode = 1;
82
+ return;
83
+ }
84
+ if (synced.pulled)
85
+ log.info(dim(synced.diagnosis.summary));
86
+ }
87
+ await finishWithBranch(root, blocking.id, blocking.filePath, gitCfg, {
88
+ dryRun,
89
+ skipIntegrationPull: true,
90
+ });
91
+ return;
92
+ }
40
93
  const ctx = await readGitContext(blocking.filePath);
41
94
  log.warn(`${yellow("Blocked")} — ${bold(blocking.id)} is still open (${blocking.title || "no title"}).`);
42
95
  if (ctx) {
@@ -49,6 +102,34 @@ export async function taskAddCommand(opts = {}) {
49
102
  process.exitCode = 1;
50
103
  return;
51
104
  }
105
+ // Preflight: sync integration BEFORE writing a new TASK file (avoids half-created TASKs).
106
+ if (!dryRun && gitCfg !== null) {
107
+ log.step("Checking integration branch…");
108
+ const synced = await ensureIntegrationSynced({
109
+ cwd: root,
110
+ remote: gitCfg.remote,
111
+ integrationBranch: gitCfg.integrationBranch,
112
+ fetch: true,
113
+ });
114
+ if (!synced.ok) {
115
+ printIntegrationSyncDiagnosis(synced.diagnosis);
116
+ log.blank();
117
+ log.info(dim("No TASK file was created. Fix the integration branch, then rerun task add."));
118
+ process.exitCode = 1;
119
+ return;
120
+ }
121
+ if (synced.pulled) {
122
+ log.ok(synced.diagnosis.summary);
123
+ }
124
+ else if (synced.diagnosis.kind === "no_remote_branch" ||
125
+ synced.diagnosis.kind === "no_remote") {
126
+ log.info(dim(synced.diagnosis.summary));
127
+ }
128
+ else {
129
+ log.info(dim(`Integration ${gitCfg.integrationBranch} is ready.`));
130
+ }
131
+ log.blank();
132
+ }
52
133
  const ideaDefault = "New work slice";
53
134
  const idea = await askInput({
54
135
  message: "What are you doing now? (short)",
@@ -106,22 +187,8 @@ export async function taskAddCommand(opts = {}) {
106
187
  }
107
188
  await writeText(filePath, markdown);
108
189
  log.ok(`Created ${bold(taskId)} → ${cyan(relFile)}`);
109
- log.blank();
110
- log.step("Starting task branch…");
111
- const started = await startTaskBranch({
112
- cwd: root,
113
- taskFile: filePath,
114
- integrationBranch: gitCfg.integrationBranch,
115
- remote: gitCfg.remote,
116
- allowDirty: true,
190
+ await finishWithBranch(root, taskId, filePath, gitCfg, {
191
+ dryRun: false,
192
+ skipIntegrationPull: true,
117
193
  });
118
- if (!started) {
119
- process.exitCode = 1;
120
- return;
121
- }
122
- log.blank();
123
- log.info(bold("Next in Cursor"));
124
- log.info(` Ask: @${relFile} — plan Scope / Acceptance Criteria`);
125
- log.info(` Agent: same file — implement`);
126
- log.info(` Ship: ${cyan(`vibeops task ship ${taskId}`)}`);
127
194
  }
@@ -1,5 +1,6 @@
1
1
  import { resolve } from "node:path";
2
2
  import { GitConfigError, requireGitConfig } from "../lib/git-config.js";
3
+ import { formatHostCliHint, formatHostCliMissingMessage } from "../lib/git-host-cli.js";
3
4
  import { detectGitHost, mergeRequestLabel } from "../lib/git-host.js";
4
5
  import { gitRemoteUrl } from "../lib/git.js";
5
6
  import { bold, cyan, dim, log } from "../lib/logger.js";
@@ -55,7 +56,7 @@ export async function taskMergeCommand(taskRef, options = {}) {
55
56
  ? null
56
57
  : await resolveOpenTaskMergeRequest(cwd, target.taskFile);
57
58
  if (!dryRun && resolvedMr === null) {
58
- log.error(`No open MR/PR for ${ctx.taskBranch} → ${ctx.baseBranch}. Run task ship or task reship first.`);
59
+ log.error(`No open MR/PR for ${ctx.taskBranch} → ${ctx.baseBranch}. Run task ship first.`);
59
60
  process.exitCode = 1;
60
61
  return;
61
62
  }
@@ -96,8 +97,11 @@ export async function taskMergeCommand(taskRef, options = {}) {
96
97
  if (state === "open") {
97
98
  const cliOk = await probeMergeRequestCli(host);
98
99
  if (!cliOk) {
99
- const tool = host === "gitlab" ? "glab" : "gh";
100
- log.error(`${tool} CLI not found — merge in the host UI, then task sync.`);
100
+ log.error(formatHostCliMissingMessage(host, `merge the ${label}`));
101
+ for (const line of formatHostCliHint(host)) {
102
+ log.info(dim(` ${line}`));
103
+ }
104
+ log.info(dim("Or merge in the host UI, then task sync."));
101
105
  process.exitCode = 1;
102
106
  return;
103
107
  }
@@ -1,5 +1,6 @@
1
1
  import { resolve } from "node:path";
2
2
  import { GitConfigError, requireGitConfig } from "../lib/git-config.js";
3
+ import { formatHostCliHint, formatHostCliMissingMessage } from "../lib/git-host-cli.js";
3
4
  import { detectGitHost, mergeRequestLabel } from "../lib/git-host.js";
4
5
  import { gitRemoteUrl } from "../lib/git.js";
5
6
  import { bold, dim, log } from "../lib/logger.js";
@@ -57,8 +58,10 @@ export async function taskReleaseCommand(options = {}) {
57
58
  }
58
59
  const cliOk = await probeMergeRequestCli(host);
59
60
  if (!cliOk) {
60
- const tool = host === "gitlab" ? "glab" : "gh";
61
- log.error(`${tool} CLI not found — create the release ${label} manually.`);
61
+ log.error(formatHostCliMissingMessage(host, `create the release ${label}`));
62
+ for (const line of formatHostCliHint(host)) {
63
+ log.info(dim(` ${line}`));
64
+ }
62
65
  process.exitCode = 1;
63
66
  return;
64
67
  }
@@ -0,0 +1,115 @@
1
+ import { execFile, spawn } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ const execFileAsync = promisify(execFile);
4
+ /** Run a CLI with inherited stdio (e.g. `gh auth login`). */
5
+ export function runInteractiveCli(bin, args) {
6
+ return new Promise((resolve, reject) => {
7
+ const child = spawn(bin, [...args], { stdio: "inherit" });
8
+ child.on("error", reject);
9
+ child.on("close", (code) => resolve(code ?? 1));
10
+ });
11
+ }
12
+ export function hostCliTool(host) {
13
+ return host === "gitlab" ? "glab" : "gh";
14
+ }
15
+ export async function commandExists(bin) {
16
+ try {
17
+ await execFileAsync(bin, ["--version"], { maxBuffer: 1024 });
18
+ return true;
19
+ }
20
+ catch {
21
+ return false;
22
+ }
23
+ }
24
+ export async function probeGhAuth() {
25
+ try {
26
+ await execFileAsync("gh", ["auth", "status"], { maxBuffer: 1024 * 1024 });
27
+ return true;
28
+ }
29
+ catch {
30
+ return false;
31
+ }
32
+ }
33
+ export async function probeGlabAuth() {
34
+ try {
35
+ await execFileAsync("glab", ["auth", "status"], { maxBuffer: 1024 * 1024 });
36
+ return true;
37
+ }
38
+ catch {
39
+ return false;
40
+ }
41
+ }
42
+ export async function probeHostCliAuth(host) {
43
+ const tool = hostCliTool(host);
44
+ if (!(await commandExists(tool)))
45
+ return "missing";
46
+ const ok = host === "gitlab" ? await probeGlabAuth() : await probeGhAuth();
47
+ return ok ? "ok" : "unauthenticated";
48
+ }
49
+ /** Install / login lines shared by init and ship/merge. */
50
+ export function formatHostCliHint(host) {
51
+ if (host === "gitlab") {
52
+ return [
53
+ `Install glab: https://gitlab.com/gitlab-org/cli#installation`,
54
+ ` brew install glab`,
55
+ `Then: glab auth login`,
56
+ ];
57
+ }
58
+ return [
59
+ `Install gh: https://cli.github.com/`,
60
+ ` brew install gh`,
61
+ `Then: gh auth login`,
62
+ ];
63
+ }
64
+ export function formatHostCliMissingMessage(host, purpose) {
65
+ const tool = hostCliTool(host);
66
+ return `${tool} CLI is required to ${purpose}.`;
67
+ }
68
+ export async function probeBrew() {
69
+ return commandExists("brew");
70
+ }
71
+ export async function brewInstall(pkg) {
72
+ await execFileAsync("brew", ["install", pkg], {
73
+ maxBuffer: 16 * 1024 * 1024,
74
+ });
75
+ }
76
+ /** Parse `owner/repo`, HTTPS, or SSH GitHub/GitLab URLs. */
77
+ export function parseOwnerRepo(input) {
78
+ const raw = input.trim();
79
+ if (raw.length === 0)
80
+ return null;
81
+ const slug = /^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+?)(?:\.git)?$/;
82
+ const fromSlug = slug.exec(raw);
83
+ if (fromSlug && !raw.includes(":") && !raw.includes("@")) {
84
+ return { owner: fromSlug[1], repo: fromSlug[2] };
85
+ }
86
+ const https = /^https?:\/\/(?:www\.)?(?:github\.com|gitlab\.com)\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/i.exec(raw);
87
+ if (https) {
88
+ return { owner: https[1], repo: https[2].replace(/\.git$/i, "") };
89
+ }
90
+ const ssh = /^(?:git@|ssh:\/\/git@)(?:github\.com|gitlab\.com)[:/]([^/]+)\/([^/]+?)(?:\.git)?$/i.exec(raw);
91
+ if (ssh) {
92
+ return { owner: ssh[1], repo: ssh[2].replace(/\.git$/i, "") };
93
+ }
94
+ return null;
95
+ }
96
+ export function remoteUrlForHost(host, owner, repo) {
97
+ if (host === "gitlab") {
98
+ return `https://gitlab.com/${owner}/${repo}.git`;
99
+ }
100
+ return `https://github.com/${owner}/${repo}.git`;
101
+ }
102
+ export async function repoExistsOnHost(host, owner, repo) {
103
+ const slug = `${owner}/${repo}`;
104
+ try {
105
+ if (host === "gitlab") {
106
+ await execFileAsync("glab", ["repo", "view", slug], { maxBuffer: 1024 * 1024 });
107
+ return true;
108
+ }
109
+ await execFileAsync("gh", ["repo", "view", slug], { maxBuffer: 1024 * 1024 });
110
+ return true;
111
+ }
112
+ catch {
113
+ return false;
114
+ }
115
+ }
@@ -0,0 +1,214 @@
1
+ import { gitFetchRemote, gitLeftRightCount, gitPullFastForwardOnly, gitRemoteBranchExists, gitRemoteUrl, gitRevParse, gitSwitchToBranch, readGitInfo, } from "./git.js";
2
+ import { cyan, dim, log } from "./logger.js";
3
+ function remoteRef(remote, branch) {
4
+ return `${remote}/${branch}`;
5
+ }
6
+ /**
7
+ * Classify why `git pull --ff-only` would fail for the integration branch.
8
+ * Call after fetch when possible so `origin/branch` is current.
9
+ */
10
+ export async function diagnoseIntegrationSync(cwd, remote, integrationBranch) {
11
+ if ((await gitRemoteUrl(cwd, remote)) === null) {
12
+ return {
13
+ ok: true,
14
+ kind: "no_remote",
15
+ summary: `No git remote "${remote}" — using local ${integrationBranch} only.`,
16
+ fixes: [],
17
+ };
18
+ }
19
+ if (!(await gitRemoteBranchExists(cwd, remote, integrationBranch))) {
20
+ return {
21
+ ok: true,
22
+ kind: "no_remote_branch",
23
+ summary: `No ${remote}/${integrationBranch} yet — local branch is fine for first push.`,
24
+ fixes: [],
25
+ };
26
+ }
27
+ const git = await readGitInfo(cwd);
28
+ if (git.dirty === true) {
29
+ return {
30
+ ok: false,
31
+ kind: "dirty",
32
+ summary: `Working tree is dirty — cannot fast-forward ${integrationBranch}.`,
33
+ fixes: [
34
+ "git status",
35
+ "git stash push -u -m \"vibeops: before sync\"",
36
+ `git checkout ${integrationBranch}`,
37
+ `git pull --ff-only ${remote} ${integrationBranch}`,
38
+ "git stash pop # if you stashed",
39
+ "vibeops task add",
40
+ ],
41
+ };
42
+ }
43
+ const localSha = await gitRevParse(cwd, integrationBranch);
44
+ const remoteSha = await gitRevParse(cwd, remoteRef(remote, integrationBranch));
45
+ if (localSha === null || remoteSha === null) {
46
+ return {
47
+ ok: false,
48
+ kind: "pull_failed",
49
+ summary: `Could not resolve ${integrationBranch} or ${remote}/${integrationBranch}.`,
50
+ fixes: [
51
+ `git fetch ${remote}`,
52
+ `git checkout ${integrationBranch}`,
53
+ `git pull --ff-only ${remote} ${integrationBranch}`,
54
+ ],
55
+ };
56
+ }
57
+ if (localSha === remoteSha) {
58
+ return {
59
+ ok: true,
60
+ kind: "ok",
61
+ summary: `${integrationBranch} matches ${remote}/${integrationBranch}.`,
62
+ fixes: [],
63
+ };
64
+ }
65
+ const counts = await gitLeftRightCount(cwd, integrationBranch, remoteRef(remote, integrationBranch));
66
+ const ahead = counts?.left ?? 0;
67
+ const behind = counts?.right ?? 0;
68
+ if (ahead > 0 && behind > 0) {
69
+ return {
70
+ ok: false,
71
+ kind: "diverged",
72
+ summary: `Local ${integrationBranch} and ${remote}/${integrationBranch} have diverged (local +${ahead}, remote +${behind}).`,
73
+ fixes: [
74
+ `git fetch ${remote}`,
75
+ `git checkout ${integrationBranch}`,
76
+ `# Prefer remote (discards local-only commits on ${integrationBranch}):`,
77
+ `git reset --hard ${remote}/${integrationBranch}`,
78
+ `# Or keep local commits: git pull --rebase ${remote} ${integrationBranch}`,
79
+ "vibeops task add",
80
+ ],
81
+ ahead,
82
+ behind,
83
+ };
84
+ }
85
+ if (ahead > 0 && behind === 0) {
86
+ return {
87
+ ok: false,
88
+ kind: "ahead",
89
+ summary: `Local ${integrationBranch} is ${ahead} commit(s) ahead of ${remote}/${integrationBranch} — fast-forward pull cannot apply.`,
90
+ fixes: [
91
+ `git checkout ${integrationBranch}`,
92
+ `# Push local commits, or reset to remote if they should not exist:`,
93
+ `git push -u ${remote} ${integrationBranch}`,
94
+ `# or: git reset --hard ${remote}/${integrationBranch}`,
95
+ "vibeops task add",
96
+ ],
97
+ ahead,
98
+ behind,
99
+ };
100
+ }
101
+ // behind only — ff-only should work
102
+ return {
103
+ ok: true,
104
+ kind: "ok",
105
+ summary: `${integrationBranch} is ${behind} commit(s) behind ${remote}/${integrationBranch} (ff-only OK).`,
106
+ fixes: [],
107
+ ahead,
108
+ behind,
109
+ };
110
+ }
111
+ export function printIntegrationSyncDiagnosis(d) {
112
+ if (d.ok) {
113
+ if (d.kind !== "ok" && d.kind !== "no_remote_branch")
114
+ log.info(dim(d.summary));
115
+ return;
116
+ }
117
+ log.error(d.summary);
118
+ if (d.fixes.length > 0) {
119
+ log.blank();
120
+ log.info(dim("Fix (run in this repo):"));
121
+ for (const line of d.fixes) {
122
+ if (line.startsWith("#"))
123
+ log.info(dim(` ${line}`));
124
+ else
125
+ log.info(` ${cyan(line)}`);
126
+ }
127
+ }
128
+ }
129
+ /**
130
+ * Fetch + ensure local integration can be used as the base for a new task branch.
131
+ * Switches to the integration branch, then `--ff-only` pull when needed. Never force-resets.
132
+ */
133
+ export async function ensureIntegrationSynced(opts) {
134
+ const { cwd, remote, integrationBranch } = opts;
135
+ const doFetch = opts.fetch !== false;
136
+ if (opts.dryRun === true) {
137
+ const d = await diagnoseIntegrationSync(cwd, remote, integrationBranch);
138
+ return { ok: d.ok || d.kind === "no_remote_branch", diagnosis: d, pulled: false };
139
+ }
140
+ if ((await gitRemoteUrl(cwd, remote)) === null) {
141
+ const d = await diagnoseIntegrationSync(cwd, remote, integrationBranch);
142
+ return { ok: true, diagnosis: d, pulled: false };
143
+ }
144
+ if (doFetch) {
145
+ try {
146
+ await gitFetchRemote(cwd, remote);
147
+ }
148
+ catch (e) {
149
+ const msg = e instanceof Error ? e.message : String(e);
150
+ log.warn(dim(`git fetch failed (${msg}) — diagnosing with local refs.`));
151
+ }
152
+ }
153
+ const git = await readGitInfo(cwd);
154
+ if (git.branch !== integrationBranch) {
155
+ const switched = await gitSwitchToBranch(cwd, integrationBranch, remote);
156
+ if (!switched) {
157
+ return {
158
+ ok: false,
159
+ diagnosis: {
160
+ ok: false,
161
+ kind: "pull_failed",
162
+ summary: `Could not switch to integration branch "${integrationBranch}".`,
163
+ fixes: [
164
+ "git status",
165
+ `git checkout ${integrationBranch}`,
166
+ `# If checkout fails due to local changes: git stash -u`,
167
+ `git pull --ff-only ${remote} ${integrationBranch}`,
168
+ "vibeops task add",
169
+ ],
170
+ },
171
+ pulled: false,
172
+ };
173
+ }
174
+ }
175
+ let diagnosis = await diagnoseIntegrationSync(cwd, remote, integrationBranch);
176
+ if (!diagnosis.ok) {
177
+ return { ok: false, diagnosis, pulled: false };
178
+ }
179
+ if (diagnosis.kind === "no_remote_branch") {
180
+ return { ok: true, diagnosis, pulled: false };
181
+ }
182
+ const localSha = await gitRevParse(cwd, integrationBranch);
183
+ const remoteSha = await gitRevParse(cwd, remoteRef(remote, integrationBranch));
184
+ if (localSha !== null && remoteSha !== null && localSha === remoteSha) {
185
+ return { ok: true, diagnosis, pulled: false };
186
+ }
187
+ try {
188
+ await gitPullFastForwardOnly(cwd, remote, integrationBranch);
189
+ diagnosis = {
190
+ ok: true,
191
+ kind: "ok",
192
+ summary: `Pulled latest ${remote}/${integrationBranch} (--ff-only).`,
193
+ fixes: [],
194
+ };
195
+ return { ok: true, diagnosis, pulled: true };
196
+ }
197
+ catch (e) {
198
+ const msg = e instanceof Error ? e.message : String(e);
199
+ diagnosis = await diagnoseIntegrationSync(cwd, remote, integrationBranch);
200
+ if (diagnosis.ok) {
201
+ diagnosis = {
202
+ ok: false,
203
+ kind: "pull_failed",
204
+ summary: `git pull --ff-only failed: ${msg}`,
205
+ fixes: [
206
+ `git checkout ${integrationBranch}`,
207
+ `git pull --ff-only ${remote} ${integrationBranch}`,
208
+ "vibeops task add",
209
+ ],
210
+ };
211
+ }
212
+ return { ok: false, diagnosis, pulled: false };
213
+ }
214
+ }