@bridge_gpt/mcp-server 0.2.30 → 0.2.31
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/build/command-assets-doctor.js +72 -0
- package/build/command-provisioning.js +157 -0
- package/build/executor/test-clock.js +3 -1
- package/build/executor/worktree.js +31 -2
- package/build/git-ignore-utils.js +67 -14
- package/build/index.js +2871 -2871
- package/build/install-bridge.js +36 -13
- package/build/start-tickets-prereqs.js +45 -0
- package/build/start-tickets.js +32 -2
- package/build/version.generated.js +1 -1
- package/package.json +3 -3
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read-only worktree Claude command-asset diagnostic (doctor-only, BAPI-664).
|
|
3
|
+
*
|
|
4
|
+
* Reports whether the current worktree contains every packaged Claude slash
|
|
5
|
+
* command under `.claude/commands/`. This probe ONLY reads command paths — it
|
|
6
|
+
* never writes, creates directories, invokes Git, spawns a process, compares or
|
|
7
|
+
* reports command file CONTENTS, or leaks raw exceptions. A committed customer
|
|
8
|
+
* version of a command counts as present regardless of its contents (the command
|
|
9
|
+
* bundle is fill-only and never refreshed), so the probe checks presence /
|
|
10
|
+
* readability, not freshness. The sole injected dependencies are `readFile` and
|
|
11
|
+
* `platform`.
|
|
12
|
+
*/
|
|
13
|
+
import path from "path";
|
|
14
|
+
import { COMMANDS } from "./commands.generated.js";
|
|
15
|
+
/** Stable user-facing relative location shown in every detail string. */
|
|
16
|
+
const COMMAND_DIR_LABEL = ".claude/commands/";
|
|
17
|
+
/** How many missing filenames to list before summarizing the remainder. */
|
|
18
|
+
const MAX_LISTED_MISSING = 5;
|
|
19
|
+
/** The `path` API for the target platform (win32 vs posix). */
|
|
20
|
+
function pathApiForPlatform(platform) {
|
|
21
|
+
return platform === "win32" ? path.win32 : path.posix;
|
|
22
|
+
}
|
|
23
|
+
/** Render a bounded, deterministic list of missing filenames (generated order). */
|
|
24
|
+
function formatMissing(missing) {
|
|
25
|
+
if (missing.length <= MAX_LISTED_MISSING) {
|
|
26
|
+
return missing.join(", ");
|
|
27
|
+
}
|
|
28
|
+
const shown = missing.slice(0, MAX_LISTED_MISSING).join(", ");
|
|
29
|
+
return `${shown} (+${missing.length - MAX_LISTED_MISSING} more)`;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Probe whether `<worktreeRoot>/.claude/commands/` contains every packaged
|
|
33
|
+
* command asset. A `readFile` that resolves counts the asset as present (any
|
|
34
|
+
* contents); a rejection (missing OR unreadable) counts it as absent. Returns
|
|
35
|
+
* `found: true` only when every packaged command filename is present, otherwise a
|
|
36
|
+
* `found: false` result with a concise, actionable, secret-free detail that
|
|
37
|
+
* points the operator back to `start-tickets` (never to manually copying or
|
|
38
|
+
* committing generated files).
|
|
39
|
+
*/
|
|
40
|
+
export async function probeWorktreeCommandAssets(worktreeRoot, deps) {
|
|
41
|
+
const filenames = Object.keys(COMMANDS);
|
|
42
|
+
if (filenames.length === 0) {
|
|
43
|
+
return {
|
|
44
|
+
found: false,
|
|
45
|
+
detail: "Packaged command bundle is empty — reinstall or rebuild the MCP server package.",
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
const api = pathApiForPlatform(deps.platform);
|
|
49
|
+
const commandsDir = api.join(worktreeRoot, ".claude", "commands");
|
|
50
|
+
const missing = [];
|
|
51
|
+
for (const filename of filenames) {
|
|
52
|
+
try {
|
|
53
|
+
await deps.readFile(api.join(commandsDir, filename));
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
// Missing OR unreadable — either way the asset is not usable. We never
|
|
57
|
+
// surface the underlying error (it can carry paths / exception text).
|
|
58
|
+
missing.push(filename);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (missing.length === 0) {
|
|
62
|
+
return {
|
|
63
|
+
found: true,
|
|
64
|
+
detail: `${filenames.length} packaged command assets present under ${COMMAND_DIR_LABEL}`,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
found: false,
|
|
69
|
+
detail: `${missing.length} of ${filenames.length} packaged command assets missing or unreadable under ` +
|
|
70
|
+
`${COMMAND_DIR_LABEL} (${formatMissing(missing)}). Re-run start-tickets to provision them.`,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared Claude command-asset provisioning (BAPI-664).
|
|
3
|
+
*
|
|
4
|
+
* Materializes the FULL packaged command bundle (`COMMANDS` from
|
|
5
|
+
* `commands.generated.ts`, the single source of truth) into a worktree's
|
|
6
|
+
* `.claude/commands/` directory so every packaged slash command is runnable from
|
|
7
|
+
* a freshly created worktree — whether spawned by the interactive `start-tickets`
|
|
8
|
+
* orchestration or by the headless conductor executor.
|
|
9
|
+
*
|
|
10
|
+
* Semantics (locked ticket decisions):
|
|
11
|
+
* - FULL bundle: every entry in `COMMANDS` is provisioned.
|
|
12
|
+
* - FILL-ONLY: a command file that already exists is treated as a customer asset
|
|
13
|
+
* and left byte-for-byte unchanged — its contents are never read-compared or
|
|
14
|
+
* refreshed. Only a missing (`ENOENT`) file is created. There is no refresh
|
|
15
|
+
* flag.
|
|
16
|
+
* - Repository-common exclude: after materialization, `.claude/commands/` is
|
|
17
|
+
* added to the worktree's Git `info/exclude` (resolved via Git for linked
|
|
18
|
+
* worktrees). `info/exclude` lives in the common Git directory shared by
|
|
19
|
+
* sibling worktrees, and an ignore rule never hides an already-tracked file,
|
|
20
|
+
* so this common scope is safe.
|
|
21
|
+
*
|
|
22
|
+
* All filesystem + command access is dependency-injected so this is unit-testable
|
|
23
|
+
* with no real I/O. This module NEVER imports `node:fs` / `child_process`, writes
|
|
24
|
+
* to stdout/stderr, or registers an MCP tool — failures are surfaced structurally
|
|
25
|
+
* and rendered by the caller's existing orchestration boundary.
|
|
26
|
+
*/
|
|
27
|
+
import path from "path";
|
|
28
|
+
import { COMMANDS } from "./commands.generated.js";
|
|
29
|
+
import { ensureGitInfoExcluded } from "./git-ignore-utils.js";
|
|
30
|
+
/** The exact exclude entry appended for the command directory (POSIX-relative). */
|
|
31
|
+
const COMMAND_DIR_EXCLUDE_ENTRY = ".claude/commands/";
|
|
32
|
+
/** Bounded, secret-free error surfaced when the packaged bundle is empty. */
|
|
33
|
+
const EMPTY_BUNDLE_ERROR = "Command provisioning failed: the packaged command bundle is empty — reinstall or rebuild the MCP server package.";
|
|
34
|
+
/**
|
|
35
|
+
* Resolve the path API for the target platform. Local (not imported from
|
|
36
|
+
* `start-tickets.ts`) to avoid a runtime import cycle.
|
|
37
|
+
*/
|
|
38
|
+
export function pathApiForCommandProvisioningPlatform(platform) {
|
|
39
|
+
return platform === "win32" ? path.win32 : path.posix;
|
|
40
|
+
}
|
|
41
|
+
/** True only for a Node `ENOENT` (missing-file) error. */
|
|
42
|
+
function isEnoentError(err) {
|
|
43
|
+
return (typeof err === "object" &&
|
|
44
|
+
err !== null &&
|
|
45
|
+
err.code === "ENOENT");
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Fill every MISSING packaged command asset into `<worktreeRoot>/.claude/commands/`
|
|
49
|
+
* without touching any existing customer file, then ensure `.claude/commands/` is
|
|
50
|
+
* added to the worktree's Git exclude. Returns a structured result; never throws
|
|
51
|
+
* for an expected filesystem/Git failure, and never leaks raw exception text,
|
|
52
|
+
* command output, or file contents.
|
|
53
|
+
*/
|
|
54
|
+
export async function provisionCommandsForWorktree(worktreeRoot, deps) {
|
|
55
|
+
const api = pathApiForCommandProvisioningPlatform(deps.platform);
|
|
56
|
+
const normalizedRoot = api.isAbsolute(worktreeRoot)
|
|
57
|
+
? api.normalize(worktreeRoot)
|
|
58
|
+
: api.resolve(deps.cwd, worktreeRoot);
|
|
59
|
+
const commandsDir = api.join(normalizedRoot, ".claude", "commands");
|
|
60
|
+
const entries = Object.entries(COMMANDS);
|
|
61
|
+
if (entries.length === 0) {
|
|
62
|
+
// No runnable slash commands could ever be provisioned — fail loudly rather
|
|
63
|
+
// than reporting a hollow success.
|
|
64
|
+
return { ok: false, error: EMPTY_BUNDLE_ERROR };
|
|
65
|
+
}
|
|
66
|
+
// Phase 1 — discover which packaged files are absent. A successful read means
|
|
67
|
+
// the customer already owns that asset; leave it untouched. Only ENOENT counts
|
|
68
|
+
// as "missing"; any other read failure is a bounded, secret-free error.
|
|
69
|
+
let fillError = null;
|
|
70
|
+
const missing = [];
|
|
71
|
+
for (const [filename, content] of entries) {
|
|
72
|
+
const target = api.join(commandsDir, filename);
|
|
73
|
+
try {
|
|
74
|
+
await deps.readFile(target);
|
|
75
|
+
}
|
|
76
|
+
catch (err) {
|
|
77
|
+
if (isEnoentError(err)) {
|
|
78
|
+
missing.push([filename, content]);
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
fillError = `Command provisioning failed: could not read existing command asset '${filename}'.`;
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
// Phase 2 — create the directory (only when something is missing) and write the
|
|
87
|
+
// absent files in bundle order using the packaged string unchanged.
|
|
88
|
+
if (!fillError && missing.length > 0) {
|
|
89
|
+
try {
|
|
90
|
+
await deps.mkdir(commandsDir, { recursive: true });
|
|
91
|
+
for (const [filename, content] of missing) {
|
|
92
|
+
await deps.writeFile(api.join(commandsDir, filename), content);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
fillError =
|
|
97
|
+
"Command provisioning failed: could not write one or more packaged command assets.";
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
// Phase 3 — ALWAYS attempt exclusion, even after a partial or failed fill, so
|
|
101
|
+
// partially bootstrapped files are never left visible to Git. The primary fill
|
|
102
|
+
// failure is preserved if both the fill and the exclusion fail.
|
|
103
|
+
let excludeError = null;
|
|
104
|
+
try {
|
|
105
|
+
await ensureGitInfoExcluded(normalizedRoot, COMMAND_DIR_EXCLUDE_ENTRY, {
|
|
106
|
+
readFile: deps.readFile,
|
|
107
|
+
writeFile: deps.writeFile,
|
|
108
|
+
mkdir: deps.mkdir,
|
|
109
|
+
runCommand: deps.runCommand,
|
|
110
|
+
platform: deps.platform,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
excludeError =
|
|
115
|
+
"Command provisioning failed: could not add '.claude/commands/' to the worktree Git exclude file.";
|
|
116
|
+
}
|
|
117
|
+
if (fillError)
|
|
118
|
+
return { ok: false, error: fillError };
|
|
119
|
+
if (excludeError)
|
|
120
|
+
return { ok: false, error: excludeError };
|
|
121
|
+
return { ok: true };
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Provision command assets for every eligible (`created`, path-bearing) row, in
|
|
125
|
+
* input order, serially. A non-`created` row or a `created` row without a usable
|
|
126
|
+
* path is returned unchanged. A per-worktree bootstrap failure marks ONLY that
|
|
127
|
+
* row `spawn-failed` (with a secret-free `Command provisioning failed: …` error)
|
|
128
|
+
* so the affected worker is skipped by all later spawn logic while its siblings
|
|
129
|
+
* proceed. A defensive per-row catch guarantees one unexpected failure cannot
|
|
130
|
+
* abort later rows or reject the overall call.
|
|
131
|
+
*/
|
|
132
|
+
export async function provisionCommandsForCreatedWorktrees(rows, deps) {
|
|
133
|
+
const out = [];
|
|
134
|
+
for (const row of rows) {
|
|
135
|
+
if (row.status !== "created" || !row.path) {
|
|
136
|
+
out.push(row);
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
try {
|
|
140
|
+
const result = await provisionCommandsForWorktree(row.path, deps);
|
|
141
|
+
if (result.ok) {
|
|
142
|
+
out.push(row);
|
|
143
|
+
}
|
|
144
|
+
else {
|
|
145
|
+
out.push({ ...row, status: "spawn-failed", error: result.error });
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
out.push({
|
|
150
|
+
...row,
|
|
151
|
+
status: "spawn-failed",
|
|
152
|
+
error: "Command provisioning failed: an unexpected error occurred while bootstrapping worktree command assets.",
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return out;
|
|
157
|
+
}
|
|
@@ -73,7 +73,9 @@ export function makeFakeExecutorDeps(clock, overrides = {}) {
|
|
|
73
73
|
throw new Error("spawnProcess not configured for this test");
|
|
74
74
|
},
|
|
75
75
|
readFile: async () => {
|
|
76
|
-
|
|
76
|
+
// Mirror real fs/promises: a missing-file rejection carries code "ENOENT"
|
|
77
|
+
// (so BAPI-664 command provisioning treats absent files as fillable).
|
|
78
|
+
throw Object.assign(new Error("ENOENT"), { code: "ENOENT" });
|
|
77
79
|
},
|
|
78
80
|
writeFile: async () => { },
|
|
79
81
|
mkdir: async () => undefined,
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import { fetchAndResolveBaseSha } from "../base-ref.js";
|
|
10
10
|
import { commandSucceeded } from "../start-tickets-prereqs.js";
|
|
11
11
|
import { createWorktreeForTicket } from "../worktree-core.js";
|
|
12
|
+
import { provisionCommandsForWorktree } from "../command-provisioning.js";
|
|
12
13
|
/**
|
|
13
14
|
* Resolve the branch for a real spawn job: `expected_branch` wins; else
|
|
14
15
|
* `feature/<ticket_key>`; else a structured contract failure.
|
|
@@ -35,6 +36,34 @@ function toWorktreeCoreDeps(deps) {
|
|
|
35
36
|
cwd: deps.cwd,
|
|
36
37
|
};
|
|
37
38
|
}
|
|
39
|
+
/** Build the command-provisioning boundary from the executor deps (BAPI-664). */
|
|
40
|
+
function toCommandProvisioningDeps(deps) {
|
|
41
|
+
return {
|
|
42
|
+
readFile: deps.readFile,
|
|
43
|
+
writeFile: deps.writeFile,
|
|
44
|
+
mkdir: deps.mkdir,
|
|
45
|
+
runCommand: deps.runCommand,
|
|
46
|
+
platform: deps.platform,
|
|
47
|
+
cwd: deps.cwd,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Finalize a resolved executor worktree by bootstrapping the packaged Claude
|
|
52
|
+
* command assets into it (BAPI-664) BEFORE the worker process is handed the
|
|
53
|
+
* worktree path. A provisioning failure is mapped into the executor's existing
|
|
54
|
+
* bounded non-success worktree result so the affected worker is not launched with
|
|
55
|
+
* a missing slash command, while the executor process stays available for later
|
|
56
|
+
* jobs. On success the existing successful ensure-worktree result shape is
|
|
57
|
+
* preserved unchanged. Errors stay secret-free (no raw filesystem exception, Git
|
|
58
|
+
* output, or command contents).
|
|
59
|
+
*/
|
|
60
|
+
async function finalizeExecutorWorktree(worktreePath, branch, deps) {
|
|
61
|
+
const provisioned = await provisionCommandsForWorktree(worktreePath, toCommandProvisioningDeps(deps));
|
|
62
|
+
if (!provisioned.ok) {
|
|
63
|
+
return { ok: false, error: provisioned.error };
|
|
64
|
+
}
|
|
65
|
+
return { ok: true, worktreePath, branch };
|
|
66
|
+
}
|
|
38
67
|
/**
|
|
39
68
|
* Ensure the worktree for a job exists, delegating to the shared Worktrunk
|
|
40
69
|
* primitive. A create failure is returned as a structured failure that the job
|
|
@@ -92,7 +121,7 @@ export async function ensureExecutorWorktree(job, options, deps, policy = {}) {
|
|
|
92
121
|
// (= `origin/<branch>`) inside the primitive, so both land at origin.
|
|
93
122
|
{ freshenFromOrigin: originRef });
|
|
94
123
|
if (row.status === "created" && typeof row.path === "string") {
|
|
95
|
-
return
|
|
124
|
+
return finalizeExecutorWorktree(row.path, branch, deps);
|
|
96
125
|
}
|
|
97
126
|
return { ok: false, error: row.error ?? `worktree creation failed for branch '${branch}'` };
|
|
98
127
|
}
|
|
@@ -117,7 +146,7 @@ export async function ensureExecutorWorktree(job, options, deps, policy = {}) {
|
|
|
117
146
|
const baseSha = resolvedBase.base_sha;
|
|
118
147
|
const row = await createWorktreeForTicket(toWorktreeCoreDeps(deps), key, { [key]: branch }, options.worktrunkBinary, baseSha, guardStaleWorktree, { alignExistingBranchTo: baseSha, verifyHeadMatches: baseSha });
|
|
119
148
|
if (row.status === "created" && typeof row.path === "string") {
|
|
120
|
-
return
|
|
149
|
+
return finalizeExecutorWorktree(row.path, branch, deps);
|
|
121
150
|
}
|
|
122
151
|
return { ok: false, error: row.error ?? `worktree creation failed for branch '${branch}'` };
|
|
123
152
|
}
|
|
@@ -4,22 +4,49 @@
|
|
|
4
4
|
* Two idempotent, exact-line appenders:
|
|
5
5
|
* - `ensureGitignored` writes the tracked `<cwd>/.gitignore` (shared with the
|
|
6
6
|
* existing `--init` scaffolding behavior).
|
|
7
|
-
* - `ensureGitInfoExcluded` writes the UNtracked
|
|
8
|
-
* used for Tier-3 file credentials
|
|
9
|
-
* must never
|
|
7
|
+
* - `ensureGitInfoExcluded` writes the UNtracked, repository-common
|
|
8
|
+
* `info/exclude`, used for Tier-3 file credentials and (BAPI-664) bootstrapped
|
|
9
|
+
* Claude command assets that must never enter version control and must never
|
|
10
|
+
* touch the tracked `.gitignore`.
|
|
11
|
+
*
|
|
12
|
+
* Linked-worktree note (BAPI-664): a worktree created by `git worktree add` (as
|
|
13
|
+
* Worktrunk does) does NOT contain a `.git` directory — it contains a `.git`
|
|
14
|
+
* *file* that points at the common Git directory. So the historical
|
|
15
|
+
* `<worktreeRoot>/.git/info/exclude` join is WRONG for a linked worktree. When a
|
|
16
|
+
* list-based `runCommand` and `platform` are injected, this helper asks Git for
|
|
17
|
+
* the authoritative path with `git rev-parse --git-path info/exclude` (relative
|
|
18
|
+
* to the worktree root); `info/exclude` lives in the common Git directory shared
|
|
19
|
+
* by all sibling worktrees, and an ignore rule never hides an already-tracked
|
|
20
|
+
* file, so the shared scope is safe. When `runCommand`/`platform` are absent the
|
|
21
|
+
* helper falls back to the legacy `<worktreeRoot>/.git/info/exclude` join, which
|
|
22
|
+
* remains correct for a main repository whose `.git` is a real directory (the
|
|
23
|
+
* SFCC dw.json caller relies on this).
|
|
10
24
|
*
|
|
11
25
|
* Both are dependency-injected so they are unit-testable without real I/O, and
|
|
12
|
-
* neither ever includes credential file contents
|
|
26
|
+
* neither ever includes credential file contents — or raw Git/command output —
|
|
27
|
+
* in an error.
|
|
13
28
|
*/
|
|
14
29
|
import path from "path";
|
|
30
|
+
/** The `path` API for the target platform (win32 vs posix). */
|
|
31
|
+
function pathApiForPlatform(platform) {
|
|
32
|
+
return platform === "win32" ? path.win32 : path.posix;
|
|
33
|
+
}
|
|
15
34
|
/** True when `content` already contains `entry` as its own exact (trimmed) line. */
|
|
16
35
|
function hasExactLine(content, entry) {
|
|
17
36
|
return content.split("\n").some((line) => line.trim() === entry);
|
|
18
37
|
}
|
|
19
|
-
/**
|
|
38
|
+
/** Detect the newline convention of existing content (CRLF wins if present). */
|
|
39
|
+
function detectNewline(content) {
|
|
40
|
+
return /\r\n/.test(content) ? "\r\n" : "\n";
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Append `entry` to `content`, inserting a separating newline only if needed and
|
|
44
|
+
* using the file's own newline convention (CRLF preserved when already present).
|
|
45
|
+
*/
|
|
20
46
|
function appendLine(content, entry) {
|
|
21
|
-
const
|
|
22
|
-
|
|
47
|
+
const newline = detectNewline(content);
|
|
48
|
+
const separator = content.length > 0 && !content.endsWith("\n") ? newline : "";
|
|
49
|
+
return content + separator + entry + newline;
|
|
23
50
|
}
|
|
24
51
|
/**
|
|
25
52
|
* Idempotently ensure `filePath` is listed in `<cwd>/.gitignore`. An absolute
|
|
@@ -41,20 +68,46 @@ export async function ensureGitignored(cwd, filePath, deps) {
|
|
|
41
68
|
await deps.writeFile(gitignorePath, appendLine(content, entry));
|
|
42
69
|
}
|
|
43
70
|
/**
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
71
|
+
* Resolve the worktree's `info/exclude` path. With an injected `runCommand` +
|
|
72
|
+
* `platform`, ask Git (`git rev-parse --git-path info/exclude`) so linked
|
|
73
|
+
* worktrees resolve to the common Git directory; a Git failure or empty path
|
|
74
|
+
* throws a bounded, output-free error. Without them, fall back to the legacy
|
|
75
|
+
* `<worktreeRoot>/.git/info/exclude` join (correct for a main repository).
|
|
48
76
|
*/
|
|
49
|
-
|
|
77
|
+
async function resolveInfoExcludeLocation(worktreeRoot, deps) {
|
|
78
|
+
if (deps.runCommand && deps.platform) {
|
|
79
|
+
const api = pathApiForPlatform(deps.platform);
|
|
80
|
+
const result = await deps.runCommand("git", ["rev-parse", "--git-path", "info/exclude"], { cwd: worktreeRoot });
|
|
81
|
+
if (result.exitCode !== 0) {
|
|
82
|
+
// Never surface raw Git stdout/stderr (it can echo paths / tokens).
|
|
83
|
+
throw new Error("Failed to resolve the worktree info/exclude path via 'git rev-parse --git-path'.");
|
|
84
|
+
}
|
|
85
|
+
const raw = result.stdout.trim();
|
|
86
|
+
if (raw.length === 0) {
|
|
87
|
+
throw new Error("'git rev-parse --git-path info/exclude' returned an empty path.");
|
|
88
|
+
}
|
|
89
|
+
const excludePath = api.isAbsolute(raw) ? api.normalize(raw) : api.resolve(worktreeRoot, raw);
|
|
90
|
+
return { excludePath, infoDir: api.dirname(excludePath) };
|
|
91
|
+
}
|
|
50
92
|
const infoDir = path.join(worktreeRoot, ".git", "info");
|
|
51
|
-
|
|
93
|
+
return { excludePath: path.join(infoDir, "exclude"), infoDir };
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Idempotently ensure `relativePath` is listed in the worktree's local-only,
|
|
97
|
+
* repository-common `info/exclude`. Resolves the exclude path via Git for linked
|
|
98
|
+
* worktrees (see module docstring) and creates its parent directory if missing.
|
|
99
|
+
* Matching is exact-line (so `dw.json` is not treated as present just because
|
|
100
|
+
* `dw.json.bak` exists) and the append respects the file's newline convention.
|
|
101
|
+
* Never edits the tracked `.gitignore`.
|
|
102
|
+
*/
|
|
103
|
+
export async function ensureGitInfoExcluded(worktreeRoot, relativePath, deps) {
|
|
104
|
+
const { excludePath, infoDir } = await resolveInfoExcludeLocation(worktreeRoot, deps);
|
|
52
105
|
let content = "";
|
|
53
106
|
try {
|
|
54
107
|
content = await deps.readFile(excludePath);
|
|
55
108
|
}
|
|
56
109
|
catch {
|
|
57
|
-
/* exclude file (or
|
|
110
|
+
/* exclude file (or info dir) doesn't exist yet */
|
|
58
111
|
}
|
|
59
112
|
if (hasExactLine(content, relativePath))
|
|
60
113
|
return;
|