@goodtek/vibeops 2.5.2 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
+ }
package/dist/lib/git.js CHANGED
@@ -4,9 +4,16 @@ import { dim, log } from "./logger.js";
4
4
  const exec = promisify(execFile);
5
5
  /** Avoid `maxBuffer` errors on large `git commit` / `git add` output. */
6
6
  const DEFAULT_GIT_MAX_BUFFER = 64 * 1024 * 1024;
7
+ /**
8
+ * Environment for every git call: output that VibeOps parses (porcelain, refusal
9
+ * messages) must not be localized.
10
+ */
11
+ export function gitEnv() {
12
+ return { ...process.env, LC_ALL: "C", LANG: "C", LANGUAGE: "C" };
13
+ }
7
14
  async function tryGit(cwd, args) {
8
15
  try {
9
- const { stdout } = await exec("git", args, { cwd });
16
+ const { stdout } = await exec("git", args, { cwd, env: gitEnv() });
10
17
  return { stdout };
11
18
  }
12
19
  catch {
@@ -16,6 +23,7 @@ async function tryGit(cwd, args) {
16
23
  export async function runGit(cwd, args, options = {}) {
17
24
  const { stdout, stderr } = await exec("git", args, {
18
25
  cwd,
26
+ env: gitEnv(),
19
27
  maxBuffer: options.maxBuffer ?? DEFAULT_GIT_MAX_BUFFER,
20
28
  });
21
29
  return { stdout, stderr };
@@ -134,69 +142,68 @@ export async function gitCreateBranch(cwd, name, startPoint) {
134
142
  await runGit(cwd, args);
135
143
  }
136
144
  /**
137
- * When only governance / VibeOps paths are dirty, stash them so `git switch` can proceed.
138
- * Callers that switch branches should restore with {@link restoreGovernanceStashAfterSwitch}.
145
+ * When only governance / VibeOps paths are dirty, set aside the TRACKED
146
+ * governance modifications so `git switch` can proceed. Untracked files are not
147
+ * stashed — they move with the switch.
148
+ *
149
+ * `refs/stash` is shared by every worktree of a repository, so this never uses
150
+ * `stash push` / `stash pop` (which act on the top entry, possibly another
151
+ * worktree's). It creates a stash commit (`git stash create`), records it in the
152
+ * stash list for safety (`git stash store`), and returns its SHA; restore with
153
+ * {@link restoreGovernanceStashAfterSwitch}, which applies and drops exactly that
154
+ * entry. Returns null when nothing was set aside.
139
155
  */
140
156
  export async function stashGovernanceIfBlocking(cwd) {
141
157
  const git = await readGitInfo(cwd);
142
158
  if (git.dirty !== true)
143
- return false;
159
+ return null;
144
160
  const gov = await gitGovernanceOnlyDirty(cwd);
145
161
  if (!gov.onlyGovernance || gov.allPaths.length === 0)
146
- return false;
147
- const entries = parsePorcelain(await gitStatusPorcelain(cwd));
148
- const tracked = [];
149
- const untracked = [];
150
- for (const e of entries) {
151
- if (!isGovernanceDocumentationPath(e.path))
152
- continue;
153
- if (e.untracked)
154
- untracked.push(e.path);
155
- else
156
- tracked.push(e.path);
157
- }
158
- let didStash = false;
159
- if (tracked.length > 0) {
160
- await runGit(cwd, [
161
- "stash",
162
- "push",
163
- "-m",
164
- "vibeops: governance before branch switch",
165
- "--",
166
- ...tracked,
167
- ]);
168
- didStash = true;
169
- }
170
- if (untracked.length > 0) {
171
- try {
172
- await runGit(cwd, [
173
- "stash",
174
- "push",
175
- "-u",
176
- "-m",
177
- "vibeops: governance untracked before branch switch",
178
- "--",
179
- ...untracked,
180
- ]);
181
- didStash = true;
182
- }
183
- catch {
184
- // Untracked generated files (e.g. cursor-implement-*.md) may not stash; switch can still proceed.
185
- }
186
- }
187
- return didStash;
162
+ return null;
163
+ const tracked = parsePorcelain(await gitStatusPorcelain(cwd))
164
+ .filter((e) => !e.untracked && isGovernanceDocumentationPath(e.path))
165
+ .map((e) => e.path);
166
+ if (tracked.length === 0)
167
+ return null;
168
+ const message = "vibeops: governance before branch switch";
169
+ const { stdout } = await runGit(cwd, ["stash", "create", message]);
170
+ const sha = stdout.trim();
171
+ if (sha.length === 0)
172
+ return null;
173
+ await runGit(cwd, ["stash", "store", "-q", "-m", message, sha]);
174
+ await runGit(cwd, ["restore", "--source=HEAD", "--staged", "--worktree", "--", ...tracked]);
175
+ return sha;
188
176
  }
189
- /** Re-apply governance paths stashed for a branch switch (e.g. new `docs/tasks/*.md`). */
190
- export async function restoreGovernanceStashAfterSwitch(cwd, didStash) {
191
- if (!didStash)
177
+ /**
178
+ * Re-apply the governance changes set aside by {@link stashGovernanceIfBlocking}
179
+ * (by SHA) and drop exactly that stash entry — never another worktree's.
180
+ */
181
+ export async function restoreGovernanceStashAfterSwitch(cwd, stashSha) {
182
+ if (stashSha === null)
192
183
  return;
193
184
  try {
194
- await runGit(cwd, ["stash", "pop"]);
195
- log.info(dim("Restored stashed governance paths on the task branch."));
185
+ await runGit(cwd, ["stash", "apply", "-q", stashSha]);
186
+ }
187
+ catch (e) {
188
+ const msg = e instanceof Error ? e.message.split("\n")[0] : String(e);
189
+ log.warn(`Could not restore governance changes (${msg}). They are kept in the stash list: ${dim(`git stash apply ${stashSha}`)}`);
190
+ return;
191
+ }
192
+ try {
193
+ const { stdout } = await runGit(cwd, ["stash", "list", "--format=%H"]);
194
+ const index = stdout.split("\n").indexOf(stashSha);
195
+ if (index >= 0) {
196
+ const ref = `stash@{${index}}`;
197
+ // Re-check right before dropping: the list is shared with other worktrees.
198
+ if ((await gitRevParse(cwd, ref)) === stashSha) {
199
+ await runGit(cwd, ["stash", "drop", "-q", ref]);
200
+ }
201
+ }
202
+ log.info(dim("Restored governance changes after the branch switch."));
196
203
  }
197
204
  catch (e) {
198
- const msg = e instanceof Error ? e.message : String(e);
199
- log.warn(`Could not restore stashed governance files (${msg}). Run ${dim("git stash pop")} manually.`);
205
+ const msg = e instanceof Error ? e.message.split("\n")[0] : String(e);
206
+ log.warn(`Restored governance changes; could not drop stash entry ${stashSha} (${msg}).`);
200
207
  }
201
208
  }
202
209
  export async function gitCheckout(cwd, ref) {
@@ -207,12 +214,14 @@ export async function gitCheckout(cwd, ref) {
207
214
  await runGit(cwd, ["switch", ref]);
208
215
  await restoreGovernanceStashAfterSwitch(cwd, stashed);
209
216
  }
210
- export async function gitCheckoutNewBranch(cwd, name, startPoint) {
217
+ export async function gitCheckoutNewBranch(cwd, name, startPoint, opts = {}) {
211
218
  const stashed = await stashGovernanceIfBlocking(cwd);
212
219
  if (stashed) {
213
220
  log.info(dim("Stashed governance-only changes (.vibeops/, docs/) before branch switch."));
214
221
  }
215
222
  const args = ["switch", "-c", name];
223
+ if (opts.noTrack === true)
224
+ args.push("--no-track");
216
225
  if (typeof startPoint === "string" && startPoint.length > 0)
217
226
  args.push(startPoint);
218
227
  await runGit(cwd, args);