@goodtek/vibeops 3.0.0 → 3.1.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,4 +1,5 @@
1
- import { gitFetchRemote, gitGovernanceOnlyDirty, gitLeftRightCount, gitPullFastForwardOnly, gitRemoteBranchExists, gitRemoteUrl, gitRevParse, gitSwitchToBranch, readGitInfo, restoreGovernanceStashAfterSwitch, stashGovernanceIfBlocking, } from "./git.js";
1
+ import { gitBranchExists, gitFetch, gitGovernanceOnlyDirty, gitLeftRightCount, gitRemoteBranchExists, gitRemoteUrl, gitRevParse, readGitInfo, } from "./git.js";
2
+ import { fastForwardLocalIntegration, logLocalIntegrationUpdate, resolveIntegrationBaseRef, } from "./git-worktree.js";
2
3
  import { cyan, dim, log } from "./logger.js";
3
4
  function remoteRef(remote, branch) {
4
5
  return `${remote}/${branch}`;
@@ -43,7 +44,7 @@ export async function diagnoseIntegrationSync(cwd, remote, integrationBranch) {
43
44
  "git status",
44
45
  `# Blocking (non-governance): ${blocking.join(", ")}${more}`,
45
46
  "git stash push -u -m \"vibeops: before sync\"",
46
- `git checkout ${integrationBranch}`,
47
+ `git switch ${integrationBranch} # or run in the worktree that has it (git worktree list)`,
47
48
  `git pull --ff-only ${remote} ${integrationBranch}`,
48
49
  "git stash pop # if you stashed",
49
50
  "vibeops task add",
@@ -52,8 +53,20 @@ export async function diagnoseIntegrationSync(cwd, remote, integrationBranch) {
52
53
  }
53
54
  // Governance-only dirty (.vibeops.json after init, docs, …) — proceed.
54
55
  }
55
- const localSha = await gitRevParse(cwd, integrationBranch);
56
56
  const remoteSha = await gitRevParse(cwd, remoteRef(remote, integrationBranch));
57
+ const localSha = (await gitBranchExists(cwd, integrationBranch))
58
+ ? await gitRevParse(cwd, `refs/heads/${integrationBranch}`)
59
+ : null;
60
+ if (localSha === null && remoteSha !== null) {
61
+ // No local integration branch (e.g. a fresh worktree) — task branches start
62
+ // from the remote-tracking ref, nothing to fast-forward.
63
+ return {
64
+ ok: true,
65
+ kind: "ok",
66
+ summary: `No local ${integrationBranch} — using ${remote}/${integrationBranch}.`,
67
+ fixes: [],
68
+ };
69
+ }
57
70
  if (localSha === null || remoteSha === null) {
58
71
  return {
59
72
  ok: false,
@@ -61,7 +74,7 @@ export async function diagnoseIntegrationSync(cwd, remote, integrationBranch) {
61
74
  summary: `Could not resolve ${integrationBranch} or ${remote}/${integrationBranch}.`,
62
75
  fixes: [
63
76
  `git fetch ${remote}`,
64
- `git checkout ${integrationBranch}`,
77
+ `git switch ${integrationBranch} # or run in the worktree that has it (git worktree list)`,
65
78
  `git pull --ff-only ${remote} ${integrationBranch}`,
66
79
  ],
67
80
  };
@@ -84,7 +97,7 @@ export async function diagnoseIntegrationSync(cwd, remote, integrationBranch) {
84
97
  summary: `Local ${integrationBranch} and ${remote}/${integrationBranch} have diverged (local +${ahead}, remote +${behind}).`,
85
98
  fixes: [
86
99
  `git fetch ${remote}`,
87
- `git checkout ${integrationBranch}`,
100
+ `git switch ${integrationBranch} # or run in the worktree that has it (git worktree list)`,
88
101
  `# Prefer remote (discards local-only commits on ${integrationBranch}):`,
89
102
  `git reset --hard ${remote}/${integrationBranch}`,
90
103
  `# Or keep local commits: git pull --rebase ${remote} ${integrationBranch}`,
@@ -100,7 +113,7 @@ export async function diagnoseIntegrationSync(cwd, remote, integrationBranch) {
100
113
  kind: "ahead",
101
114
  summary: `Local ${integrationBranch} is ${ahead} commit(s) ahead of ${remote}/${integrationBranch} — fast-forward pull cannot apply.`,
102
115
  fixes: [
103
- `git checkout ${integrationBranch}`,
116
+ `git switch ${integrationBranch} # or run in the worktree that has it (git worktree list)`,
104
117
  `# Push local commits, or reset to remote if they should not exist:`,
105
118
  `git push -u ${remote} ${integrationBranch}`,
106
119
  `# or: git reset --hard ${remote}/${integrationBranch}`,
@@ -139,94 +152,87 @@ export function printIntegrationSyncDiagnosis(d) {
139
152
  }
140
153
  }
141
154
  /**
142
- * Fetch + ensure local integration can be used as the base for a new task branch.
143
- * Switches to the integration branch, then `--ff-only` pull when needed. Never force-resets.
144
- * Governance-only dirt is stashed around switch/pull so post-`init` `.vibeops.json` edits do not block.
155
+ * Worktree-safe integration preflight (D-006). Never checks out the integration
156
+ * branch (another worktree may own it) and never force-resets:
157
+ * 1. `git fetch <remote> <integration>`;
158
+ * 2. diagnose (app dirt / local ahead / diverged → refuse with fixes);
159
+ * 3. fast-forward the local integration ref only where safe
160
+ * ({@link fastForwardLocalIntegration});
161
+ * 4. return the base ref for the task branch (`<remote>/<integration>`).
145
162
  */
146
163
  export async function ensureIntegrationSynced(opts) {
147
164
  const { cwd, remote, integrationBranch } = opts;
148
165
  const doFetch = opts.fetch !== false;
149
166
  if (opts.dryRun === true) {
150
167
  const d = await diagnoseIntegrationSync(cwd, remote, integrationBranch);
151
- return { ok: d.ok || d.kind === "no_remote_branch", diagnosis: d, pulled: false };
152
- }
153
- if ((await gitRemoteUrl(cwd, remote)) === null) {
154
- const d = await diagnoseIntegrationSync(cwd, remote, integrationBranch);
155
- return { ok: true, diagnosis: d, pulled: false };
168
+ const ok = d.ok || d.kind === "no_remote_branch";
169
+ return {
170
+ ok,
171
+ diagnosis: d,
172
+ pulled: false,
173
+ baseRef: ok ? await resolveIntegrationBaseRef(cwd, remote, integrationBranch) : null,
174
+ };
156
175
  }
157
- if (doFetch) {
176
+ if ((await gitRemoteUrl(cwd, remote)) !== null && doFetch) {
158
177
  try {
159
- await gitFetchRemote(cwd, remote);
178
+ await gitFetch(cwd, remote, integrationBranch);
160
179
  }
161
180
  catch (e) {
162
181
  const msg = e instanceof Error ? e.message : String(e);
163
182
  log.warn(dim(`git fetch failed (${msg}) — diagnosing with local refs.`));
164
183
  }
165
184
  }
166
- const git = await readGitInfo(cwd);
167
- if (git.branch !== integrationBranch) {
168
- const switched = await gitSwitchToBranch(cwd, integrationBranch, remote);
169
- if (!switched) {
170
- return {
171
- ok: false,
172
- diagnosis: {
173
- ok: false,
174
- kind: "pull_failed",
175
- summary: `Could not switch to integration branch "${integrationBranch}".`,
176
- fixes: [
177
- "git status",
178
- `git checkout ${integrationBranch}`,
179
- `# If checkout fails due to local changes: git stash -u`,
180
- `git pull --ff-only ${remote} ${integrationBranch}`,
181
- "vibeops task add",
182
- ],
183
- },
184
- pulled: false,
185
- };
186
- }
187
- }
188
- let diagnosis = await diagnoseIntegrationSync(cwd, remote, integrationBranch);
185
+ const diagnosis = await diagnoseIntegrationSync(cwd, remote, integrationBranch);
189
186
  if (!diagnosis.ok) {
190
- return { ok: false, diagnosis, pulled: false };
187
+ return { ok: false, diagnosis, pulled: false, baseRef: null };
191
188
  }
192
- if (diagnosis.kind === "no_remote_branch") {
193
- return { ok: true, diagnosis, pulled: false };
189
+ const baseRef = await resolveIntegrationBaseRef(cwd, remote, integrationBranch);
190
+ if (baseRef === null) {
191
+ return {
192
+ ok: false,
193
+ diagnosis: {
194
+ ok: false,
195
+ kind: "pull_failed",
196
+ summary: `Integration branch "${integrationBranch}" not found locally or on ${remote}.`,
197
+ fixes: ["vibeops init # or create and push the integration branch"],
198
+ },
199
+ pulled: false,
200
+ baseRef: null,
201
+ };
194
202
  }
195
- const localSha = await gitRevParse(cwd, integrationBranch);
196
- const remoteSha = await gitRevParse(cwd, remoteRef(remote, integrationBranch));
197
- if (localSha !== null && remoteSha !== null && localSha === remoteSha) {
198
- return { ok: true, diagnosis, pulled: false };
203
+ if (diagnosis.kind === "no_remote" || diagnosis.kind === "no_remote_branch") {
204
+ return { ok: true, diagnosis, pulled: false, baseRef };
199
205
  }
200
- // Stash governance dirt so ff-only pull cannot be blocked by `.vibeops.json` etc.
201
- const stashed = await stashGovernanceIfBlocking(cwd);
202
206
  try {
203
- await gitPullFastForwardOnly(cwd, remote, integrationBranch);
204
- diagnosis = {
205
- ok: true,
206
- kind: "ok",
207
- summary: `Pulled latest ${remote}/${integrationBranch} (--ff-only).`,
208
- fixes: [],
209
- };
210
- return { ok: true, diagnosis, pulled: true };
207
+ const update = await fastForwardLocalIntegration(cwd, remote, integrationBranch);
208
+ logLocalIntegrationUpdate(update, remote, integrationBranch);
209
+ if (update.kind === "fast_forwarded") {
210
+ return {
211
+ ok: true,
212
+ diagnosis: {
213
+ ok: true,
214
+ kind: "ok",
215
+ summary: `Fast-forwarded ${integrationBranch} to ${remote}/${integrationBranch}.`,
216
+ fixes: [],
217
+ },
218
+ pulled: true,
219
+ baseRef,
220
+ };
221
+ }
222
+ return { ok: true, diagnosis, pulled: false, baseRef };
211
223
  }
212
224
  catch (e) {
213
225
  const msg = e instanceof Error ? e.message : String(e);
214
- diagnosis = await diagnoseIntegrationSync(cwd, remote, integrationBranch);
215
- if (diagnosis.ok) {
216
- diagnosis = {
226
+ return {
227
+ ok: false,
228
+ diagnosis: {
217
229
  ok: false,
218
230
  kind: "pull_failed",
219
- summary: `git pull --ff-only failed: ${msg}`,
220
- fixes: [
221
- `git checkout ${integrationBranch}`,
222
- `git pull --ff-only ${remote} ${integrationBranch}`,
223
- "vibeops task add",
224
- ],
225
- };
226
- }
227
- return { ok: false, diagnosis, pulled: false };
228
- }
229
- finally {
230
- await restoreGovernanceStashAfterSwitch(cwd, stashed);
231
+ summary: `Could not fast-forward ${integrationBranch}: ${msg}`,
232
+ fixes: [`git merge --ff-only ${remote}/${integrationBranch} # in the worktree that has ${integrationBranch}`],
233
+ },
234
+ pulled: false,
235
+ baseRef: null,
236
+ };
231
237
  }
232
238
  }
@@ -0,0 +1,214 @@
1
+ import { realpath } from "node:fs/promises";
2
+ import { resolve } from "node:path";
3
+ import { gitBranchExists, gitRemoteBranchExists, gitRemoteUrl, gitRevParse, restoreGovernanceStashAfterSwitch, runGit, stashGovernanceIfBlocking, } from "./git.js";
4
+ import { dim, log } from "./logger.js";
5
+ /** Parse `git worktree list --porcelain` output. */
6
+ export function parseWorktreePorcelain(stdout) {
7
+ const out = [];
8
+ for (const block of stdout.split(/\n\s*\n/)) {
9
+ const lines = block.split("\n").filter((l) => l.length > 0);
10
+ if (lines.length === 0)
11
+ continue;
12
+ let path = null;
13
+ let head = null;
14
+ let branch = null;
15
+ let bare = false;
16
+ let detached = false;
17
+ for (const line of lines) {
18
+ if (line.startsWith("worktree "))
19
+ path = line.slice("worktree ".length);
20
+ else if (line.startsWith("HEAD "))
21
+ head = line.slice("HEAD ".length);
22
+ else if (line.startsWith("branch ")) {
23
+ const ref = line.slice("branch ".length);
24
+ branch = ref.startsWith("refs/heads/") ? ref.slice("refs/heads/".length) : ref;
25
+ }
26
+ else if (line === "bare")
27
+ bare = true;
28
+ else if (line === "detached")
29
+ detached = true;
30
+ }
31
+ if (path !== null)
32
+ out.push({ path, head, branch, bare, detached });
33
+ }
34
+ return out;
35
+ }
36
+ export async function listWorktrees(cwd) {
37
+ const { stdout } = await runGit(cwd, ["worktree", "list", "--porcelain"]);
38
+ return parseWorktreePorcelain(stdout);
39
+ }
40
+ async function canonical(p) {
41
+ try {
42
+ return await realpath(p);
43
+ }
44
+ catch {
45
+ return resolve(p);
46
+ }
47
+ }
48
+ /** Absolute top-level path of the worktree containing `cwd`. */
49
+ export async function currentWorktreeRoot(cwd) {
50
+ const { stdout } = await runGit(cwd, ["rev-parse", "--show-toplevel"]);
51
+ return canonical(stdout.trim());
52
+ }
53
+ /**
54
+ * Path of another worktree (not the one containing `cwd`) that has `branch`
55
+ * checked out, or null.
56
+ */
57
+ export async function branchCheckedOutElsewhere(cwd, branch) {
58
+ const here = await currentWorktreeRoot(cwd);
59
+ for (const wt of await listWorktrees(cwd)) {
60
+ if (wt.branch !== branch)
61
+ continue;
62
+ if ((await canonical(wt.path)) === here)
63
+ continue;
64
+ return wt.path;
65
+ }
66
+ return null;
67
+ }
68
+ /** Branch checked out in the worktree containing `cwd` (null when detached). */
69
+ async function currentBranch(cwd) {
70
+ try {
71
+ const { stdout } = await runGit(cwd, ["symbolic-ref", "--quiet", "--short", "HEAD"]);
72
+ const b = stdout.trim();
73
+ return b.length > 0 ? b : null;
74
+ }
75
+ catch {
76
+ return null;
77
+ }
78
+ }
79
+ /**
80
+ * Ref new task branches start from: `<remote>/<integration>` when the
81
+ * remote-tracking ref exists, else the local integration branch (no remote yet).
82
+ * Null when neither exists.
83
+ */
84
+ export async function resolveIntegrationBaseRef(cwd, remote, integrationBranch) {
85
+ if ((await gitRemoteUrl(cwd, remote)) !== null &&
86
+ (await gitRemoteBranchExists(cwd, remote, integrationBranch))) {
87
+ return `${remote}/${integrationBranch}`;
88
+ }
89
+ if (await gitBranchExists(cwd, integrationBranch))
90
+ return integrationBranch;
91
+ return null;
92
+ }
93
+ /**
94
+ * Fast-forward the local integration branch to `<remote>/<integration>` without
95
+ * ever forcing and without touching another worktree:
96
+ * - checked out in this worktree → `git merge --ff-only <remote>/<integration>`;
97
+ * - otherwise → `git fetch . refs/remotes/<remote>/<integration>:refs/heads/<integration>`. git itself
98
+ * refuses a non-fast-forward update and refuses to update a branch checked out
99
+ * in any worktree; those refusals are reported (`not_fast_forward` /
100
+ * `owned_elsewhere`), not treated as failures. No check-then-update race.
101
+ * A missing local branch is not created.
102
+ */
103
+ export async function fastForwardLocalIntegration(cwd, remote, integrationBranch) {
104
+ const remoteRef = `${remote}/${integrationBranch}`;
105
+ if (!(await gitRemoteBranchExists(cwd, remote, integrationBranch))) {
106
+ return { kind: "no_remote_branch" };
107
+ }
108
+ if (!(await gitBranchExists(cwd, integrationBranch)))
109
+ return { kind: "no_local_branch" };
110
+ if ((await currentBranch(cwd)) === integrationBranch) {
111
+ const before = await gitRevParse(cwd, `refs/heads/${integrationBranch}`);
112
+ const remoteSha = await gitRevParse(cwd, `refs/remotes/${remoteRef}`);
113
+ if (before === remoteSha)
114
+ return { kind: "up_to_date" };
115
+ const stashed = await stashGovernanceIfBlocking(cwd);
116
+ try {
117
+ await runGit(cwd, ["merge", "--ff-only", remoteRef]);
118
+ }
119
+ catch (e) {
120
+ if (/not possible to fast-forward|diverg|non-fast-forward/i.test(errorText(e))) {
121
+ return { kind: "not_fast_forward" };
122
+ }
123
+ throw e;
124
+ }
125
+ finally {
126
+ await restoreGovernanceStashAfterSwitch(cwd, stashed);
127
+ }
128
+ return { kind: "fast_forwarded", where: "here" };
129
+ }
130
+ const before = await gitRevParse(cwd, `refs/heads/${integrationBranch}`);
131
+ try {
132
+ // Fetch from this repository (`.`) into the local branch: same git safety as
133
+ // `git fetch <remote> <b>:<b>` (refuses non-fast-forward, refuses a branch
134
+ // checked out in any worktree) using the remote-tracking ref the caller just
135
+ // fetched — no second network round-trip.
136
+ await runGit(cwd, [
137
+ "fetch",
138
+ ".",
139
+ `refs/remotes/${remoteRef}:refs/heads/${integrationBranch}`,
140
+ ]);
141
+ }
142
+ catch (e) {
143
+ const text = errorText(e);
144
+ if (/refusing to fetch into branch|checked out at/i.test(text)) {
145
+ const owner = await branchCheckedOutElsewhere(cwd, integrationBranch);
146
+ return { kind: "owned_elsewhere", worktree: owner ?? "(another worktree)" };
147
+ }
148
+ if (/non-fast-forward|rejected/i.test(text))
149
+ return { kind: "not_fast_forward" };
150
+ throw e;
151
+ }
152
+ const after = await gitRevParse(cwd, `refs/heads/${integrationBranch}`);
153
+ return after === before ? { kind: "up_to_date" } : { kind: "fast_forwarded", where: "ref" };
154
+ }
155
+ function errorText(e) {
156
+ if (e && typeof e === "object") {
157
+ const o = e;
158
+ return `${typeof o.stderr === "string" ? o.stderr : ""}\n${typeof o.message === "string" ? o.message : ""}`;
159
+ }
160
+ return String(e);
161
+ }
162
+ /** One-line log for {@link fastForwardLocalIntegration} results. */
163
+ export function logLocalIntegrationUpdate(r, remote, integrationBranch) {
164
+ switch (r.kind) {
165
+ case "fast_forwarded":
166
+ log.ok(`Fast-forwarded local ${integrationBranch} to ${remote}/${integrationBranch}`);
167
+ return;
168
+ case "owned_elsewhere":
169
+ log.info(dim(`Local ${integrationBranch} is checked out in worktree ${r.worktree} — left unchanged (update it there).`));
170
+ return;
171
+ case "not_fast_forward":
172
+ log.warn(`Local ${integrationBranch} has commits not on ${remote}/${integrationBranch} — left unchanged (never forced).`);
173
+ return;
174
+ default:
175
+ return;
176
+ }
177
+ }
178
+ /**
179
+ * Move HEAD off the task branch so it can be deleted. Switches to the integration
180
+ * branch when no other worktree has it checked out; otherwise detaches at
181
+ * `<remote>/<integration>` (or the local integration ref without a remote).
182
+ */
183
+ export async function leaveTaskBranch(cwd, remote, integrationBranch) {
184
+ const owner = await branchCheckedOutElsewhere(cwd, integrationBranch);
185
+ const base = await resolveIntegrationBaseRef(cwd, remote, integrationBranch);
186
+ if (base === null) {
187
+ return {
188
+ ok: false,
189
+ message: `Integration branch "${integrationBranch}" not found locally or on ${remote}.`,
190
+ };
191
+ }
192
+ const stashed = await stashGovernanceIfBlocking(cwd);
193
+ try {
194
+ if (owner === null) {
195
+ if (await gitBranchExists(cwd, integrationBranch)) {
196
+ await runGit(cwd, ["switch", integrationBranch]);
197
+ }
198
+ else {
199
+ await runGit(cwd, ["switch", "-c", integrationBranch, "--track", base]);
200
+ }
201
+ return { ok: true, on: "integration", ref: integrationBranch };
202
+ }
203
+ await runGit(cwd, ["switch", "--detach", base]);
204
+ log.info(dim(`${integrationBranch} is checked out in worktree ${owner} — detached this worktree at ${base}.`));
205
+ return { ok: true, on: "detached", ref: base };
206
+ }
207
+ catch (e) {
208
+ const msg = e instanceof Error ? e.message : String(e);
209
+ return { ok: false, message: `Could not leave the task branch: ${msg}` };
210
+ }
211
+ finally {
212
+ await restoreGovernanceStashAfterSwitch(cwd, stashed);
213
+ }
214
+ }