changebook 0.4.8 → 0.4.10
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/README.md +3 -0
- package/dist/analyze.js +5 -3
- package/dist/context.js +76 -29
- package/dist/credentials.js +42 -0
- package/dist/git.js +77 -2
- package/dist/guard.js +267 -23
- package/dist/impact.js +630 -0
- package/dist/import.js +2 -2
- package/dist/index.js +54 -1
- package/dist/login.js +61 -1
- package/dist/supabase.js +48 -4
- package/dist/sync.js +94 -23
- package/dist/tools.js +101 -5
- package/package.json +1 -1
- package/server.json +2 -2
package/README.md
CHANGED
|
@@ -50,6 +50,9 @@ your other servers.
|
|
|
50
50
|
| `changebook analyze [dir]` | Analyze the repo's uncommitted changes (`git diff HEAD`) and update the atlas — same pipeline as the VS Code extension, no editor needed. |
|
|
51
51
|
| `changebook analyze --commit [ref]` | Analyze one commit. Deduped by hash server-side, so re-runs never bill. |
|
|
52
52
|
| `changebook hook install\|uninstall\|status [dir]` | Git hooks: every new commit is analyzed in the background (post-commit, never blocks), and the signal guard warns before you commit to a module with an open alert (pre-commit). One pair of hooks covers Claude Code, Codex and manual commits — they all commit through git. |
|
|
53
|
+
| `changebook hook-context install\|uninstall\|status [dir]` | Claude Code `SessionStart` hook: pushes the fresh atlas map into **every** session at turn 0 — no tool call to remember, and generated on the spot so it can't go stale. Writes to `.claude/settings.json`; running the command **is** the consent, and it refuses to touch a config it can't parse. |
|
|
54
|
+
| `changebook hook-impact install\|uninstall\|status [dir]` | Claude Code `PreToolUse` hook: **before every edit**, tells the agent which modules depend on the file it is about to touch, plus any open alert and repeat-offender history. Never blocks an edit, never touches the network on the critical path (reads a short-lived cache in `.git/` and refreshes it out of band), warns once per file per session, and stays **silent** when there is nothing to say. |
|
|
55
|
+
| `changebook impact` / `changebook context [dir]` | What those two hooks run. Both read from stdin/disk, print a JSON payload (or nothing) and always exit 0 — you don't call them by hand. |
|
|
53
56
|
| `changebook guard [dir]` | What the pre-commit hook runs: checks staged files against the atlas' open alerts. Warn-only and fail-open by default; `CHANGEBOOK_GUARD=block` makes findings abort the commit (bypass once with `git commit --no-verify`), `CHANGEBOOK_GUARD=off` silences it. |
|
|
54
57
|
| `changebook sync [dir]` | Refresh the product map inside `CLAUDE.md`/`AGENTS.md`. |
|
|
55
58
|
| `changebook init [dir]` | login + register MCP server + install hook + sync, in one go. |
|
package/dist/analyze.js
CHANGED
|
@@ -6,12 +6,12 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import * as path from "node:path";
|
|
8
8
|
import { atlasWebUrl } from "./browser.js";
|
|
9
|
-
import { commitDiff, execFileAsync, GIT_MAX_BUFFER_BYTES, gitErrorMessage, MAX_DIFF_CHARACTERS, usableSummary, } from "./git.js";
|
|
9
|
+
import { commitDiff, execFileAsync, FICHEROS_GENERADOS, GIT_MAX_BUFFER_BYTES, gitErrorMessage, MAX_DIFF_CHARACTERS, projectNameFor, usableSummary, } from "./git.js";
|
|
10
10
|
import { canonicalDiffHash } from "./canonical.js";
|
|
11
11
|
import { optimizeTokensForAI, truncateAtFileBoundary } from "./optimize.js";
|
|
12
12
|
export async function analyze(db, options = {}) {
|
|
13
13
|
const cwd = path.resolve(options.dir ?? process.cwd());
|
|
14
|
-
const projectName =
|
|
14
|
+
const projectName = projectNameFor(cwd);
|
|
15
15
|
let rawDiff;
|
|
16
16
|
let commitHash;
|
|
17
17
|
let committedAt;
|
|
@@ -124,7 +124,9 @@ async function commitMeta(cwd, ref) {
|
|
|
124
124
|
}
|
|
125
125
|
async function gitDiffHead(cwd) {
|
|
126
126
|
try {
|
|
127
|
-
|
|
127
|
+
// Mismos ficheros excluidos que en commitDiff: el atlas no se analiza a sí
|
|
128
|
+
// mismo (ver FICHEROS_GENERADOS).
|
|
129
|
+
const { stdout } = await execFileAsync("git", ["diff", "HEAD", "-U0", "--", ...FICHEROS_GENERADOS], {
|
|
128
130
|
cwd,
|
|
129
131
|
encoding: "utf8",
|
|
130
132
|
maxBuffer: GIT_MAX_BUFFER_BYTES,
|
package/dist/context.js
CHANGED
|
@@ -28,6 +28,38 @@ import { fetchBriefSection } from "./sync.js";
|
|
|
28
28
|
import { commitAliasesShort, derivaContraHead } from "./tools.js";
|
|
29
29
|
/** Hard ceiling on the critical path: past this, emit nothing and move on. */
|
|
30
30
|
const CONTEXT_TIMEOUT_MS = 2_000;
|
|
31
|
+
/**
|
|
32
|
+
* A timeout that can lose the race without punishing the winner.
|
|
33
|
+
*
|
|
34
|
+
* The naive form — `new Promise(r => setTimeout(r, ms))` raced against the real
|
|
35
|
+
* work — has a bug that measurement makes obvious and reading never does: when
|
|
36
|
+
* the work wins, the timer is still pending, and Node will not exit until it
|
|
37
|
+
* fires. The process is done and just sits there. Measured 2026-07-26:
|
|
38
|
+
* `changebook context` took 2.18s wall clock to produce a payload it already had
|
|
39
|
+
* in ~200ms, on the session-start path, under a doc comment promising it would
|
|
40
|
+
* "never block it, slow it, or exit non-zero". It had been slowing every session
|
|
41
|
+
* by two seconds.
|
|
42
|
+
*
|
|
43
|
+
* `unref()` is the fix: the timer still fires if anything else is keeping the
|
|
44
|
+
* loop alive, but it never keeps the loop alive by itself. `cancelar()` is belt
|
|
45
|
+
* and braces for the long-lived case (the MCP server), where an unref'd timer
|
|
46
|
+
* would still fire pointlessly.
|
|
47
|
+
*
|
|
48
|
+
* Shared by both hooks on purpose. The defect existed independently in two
|
|
49
|
+
* places, which is the usual sign that the shape — not the instance — was wrong.
|
|
50
|
+
*/
|
|
51
|
+
export function presupuestoDeTiempo(ms) {
|
|
52
|
+
let cancelar = () => { };
|
|
53
|
+
const vencido = new Promise((resolve) => {
|
|
54
|
+
const t = setTimeout(() => resolve(null), ms);
|
|
55
|
+
t.unref?.();
|
|
56
|
+
cancelar = () => {
|
|
57
|
+
clearTimeout(t);
|
|
58
|
+
resolve(null);
|
|
59
|
+
};
|
|
60
|
+
});
|
|
61
|
+
return { vencido, cancelar };
|
|
62
|
+
}
|
|
31
63
|
async function buildPayload(db, dir) {
|
|
32
64
|
// Logged out → silent no-op. A fresh clone with the hook installed but no
|
|
33
65
|
// session must open exactly as fast as before.
|
|
@@ -60,24 +92,20 @@ async function buildPayload(db, dir) {
|
|
|
60
92
|
}
|
|
61
93
|
return drift ? `${section}\n\n${drift}` : section;
|
|
62
94
|
}
|
|
63
|
-
// ── SessionStart hook install (opt-in, per repo) ─────────────────────────────
|
|
64
|
-
//
|
|
65
|
-
// The push channel lives in `.claude/settings.json` under hooks.SessionStart.
|
|
66
|
-
// Consent is the act of running `changebook hook-context install` (or saying
|
|
67
|
-
// yes to init's offer) — this file is often committed and shared, so we NEVER
|
|
68
|
-
// write it silently and NEVER clobber a config we can't parse.
|
|
69
95
|
// `2>/dev/null || true`: if `changebook` isn't on PATH for some teammate, the
|
|
70
96
|
// shell error is swallowed and the hook exits 0 — a missing binary must not
|
|
71
|
-
// make a
|
|
72
|
-
//
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
97
|
+
// make a hook look failed. Both commands emit only the JSON payload (or
|
|
98
|
+
// nothing) on stdout.
|
|
99
|
+
export const CONTEXT_HOOK = {
|
|
100
|
+
event: "SessionStart",
|
|
101
|
+
command: "changebook context 2>/dev/null || true",
|
|
102
|
+
marker: "changebook context",
|
|
103
|
+
};
|
|
76
104
|
function settingsPath(dir) {
|
|
77
105
|
return path.join(path.resolve(dir), ".claude", "settings.json");
|
|
78
106
|
}
|
|
79
|
-
function groupHasMarker(g) {
|
|
80
|
-
return (g.hooks ?? []).some((h) => (h.command ?? "").includes(
|
|
107
|
+
function groupHasMarker(g, marker) {
|
|
108
|
+
return (g.hooks ?? []).some((h) => (h.command ?? "").includes(marker));
|
|
81
109
|
}
|
|
82
110
|
function readSettings(file) {
|
|
83
111
|
let raw;
|
|
@@ -97,53 +125,71 @@ function readSettings(file) {
|
|
|
97
125
|
return null; // unparseable → caller must refuse, never clobber
|
|
98
126
|
}
|
|
99
127
|
}
|
|
100
|
-
export function
|
|
128
|
+
export function settingsHookInstalled(dir, spec) {
|
|
101
129
|
const s = readSettings(settingsPath(dir));
|
|
102
|
-
return Boolean(s?.hooks?.
|
|
130
|
+
return Boolean(s?.hooks?.[spec.event]?.some((g) => groupHasMarker(g, spec.marker)));
|
|
103
131
|
}
|
|
104
|
-
export function
|
|
132
|
+
export function installSettingsHook(dir, spec) {
|
|
105
133
|
const file = settingsPath(dir);
|
|
106
134
|
const settings = readSettings(file);
|
|
107
135
|
if (settings === null) {
|
|
108
136
|
throw new Error(`Refusing to touch ${file}: it isn't valid JSON. Fix or remove it, then retry.`);
|
|
109
137
|
}
|
|
110
138
|
const hooks = (settings.hooks ??= {});
|
|
111
|
-
const
|
|
112
|
-
if (
|
|
139
|
+
const list = (hooks[spec.event] ??= []);
|
|
140
|
+
if (list.some((g) => groupHasMarker(g, spec.marker)))
|
|
113
141
|
return "already";
|
|
114
|
-
|
|
115
|
-
|
|
142
|
+
// El matcher solo se escribe si lo hay: un `matcher` presente y vacío es
|
|
143
|
+
// "todas las tools" en Claude Code, y en SessionStart no significa nada.
|
|
144
|
+
// Escribir la clave igualmente dejaría un campo mudo en un archivo que la
|
|
145
|
+
// gente lee y commitea.
|
|
146
|
+
list.push({
|
|
147
|
+
...(spec.matcher ? { matcher: spec.matcher } : {}),
|
|
148
|
+
hooks: [{ type: "command", command: spec.command }],
|
|
116
149
|
});
|
|
117
150
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
118
151
|
fs.writeFileSync(file, JSON.stringify(settings, null, 2) + "\n");
|
|
119
152
|
return "installed";
|
|
120
153
|
}
|
|
121
|
-
export function
|
|
154
|
+
export function uninstallSettingsHook(dir, spec) {
|
|
122
155
|
const file = settingsPath(dir);
|
|
123
156
|
const settings = readSettings(file);
|
|
124
|
-
if (!settings
|
|
157
|
+
if (!settings?.hooks?.[spec.event])
|
|
125
158
|
return "absent";
|
|
126
|
-
const before = settings.hooks.
|
|
159
|
+
const before = settings.hooks[spec.event];
|
|
127
160
|
// Drop our command from every group, then drop groups left empty. Foreign
|
|
128
161
|
// hooks in the same group (unusual, but possible) are preserved.
|
|
129
162
|
const after = before
|
|
130
163
|
.map((g) => ({
|
|
131
164
|
...g,
|
|
132
|
-
hooks: (g.hooks ?? []).filter((h) => !(h.command ?? "").includes(
|
|
165
|
+
hooks: (g.hooks ?? []).filter((h) => !(h.command ?? "").includes(spec.marker)),
|
|
133
166
|
}))
|
|
134
167
|
.filter((g) => (g.hooks ?? []).length > 0);
|
|
135
|
-
if (after.length === before.length &&
|
|
168
|
+
if (after.length === before.length &&
|
|
169
|
+
before.every((g) => !groupHasMarker(g, spec.marker))) {
|
|
136
170
|
return "absent";
|
|
137
171
|
}
|
|
138
172
|
if (after.length > 0)
|
|
139
|
-
settings.hooks.
|
|
173
|
+
settings.hooks[spec.event] = after;
|
|
140
174
|
else
|
|
141
|
-
delete settings.hooks.
|
|
175
|
+
delete settings.hooks[spec.event];
|
|
142
176
|
if (Object.keys(settings.hooks).length === 0)
|
|
143
177
|
delete settings.hooks;
|
|
144
178
|
fs.writeFileSync(file, JSON.stringify(settings, null, 2) + "\n");
|
|
145
179
|
return "removed";
|
|
146
180
|
}
|
|
181
|
+
// Envoltorios del canal SessionStart. Se mantienen con su nombre porque son la
|
|
182
|
+
// superficie que ya consume index.ts (y, a través de él, `init`): generalizar
|
|
183
|
+
// por dentro no es motivo para renombrar por fuera.
|
|
184
|
+
export function contextHookInstalled(dir) {
|
|
185
|
+
return settingsHookInstalled(dir, CONTEXT_HOOK);
|
|
186
|
+
}
|
|
187
|
+
export function installContextHook(dir) {
|
|
188
|
+
return installSettingsHook(dir, CONTEXT_HOOK);
|
|
189
|
+
}
|
|
190
|
+
export function uninstallContextHook(dir) {
|
|
191
|
+
return uninstallSettingsHook(dir, CONTEXT_HOOK);
|
|
192
|
+
}
|
|
147
193
|
/**
|
|
148
194
|
* The pulse and the brief, composed. Separate from printContext so the WHOLE
|
|
149
195
|
* thing — including the pulse — stays inside the single timeout race: adding
|
|
@@ -170,11 +216,12 @@ async function buildSessionContext(db, dir) {
|
|
|
170
216
|
}
|
|
171
217
|
export async function printContext(db, dir) {
|
|
172
218
|
try {
|
|
173
|
-
const
|
|
219
|
+
const { vencido, cancelar } = presupuestoDeTiempo(CONTEXT_TIMEOUT_MS);
|
|
174
220
|
const additionalContext = await Promise.race([
|
|
175
221
|
buildSessionContext(db, dir),
|
|
176
|
-
|
|
222
|
+
vencido,
|
|
177
223
|
]);
|
|
224
|
+
cancelar();
|
|
178
225
|
if (!additionalContext)
|
|
179
226
|
return;
|
|
180
227
|
// The SessionStart contract: stdout JSON whose additionalContext is
|
package/dist/credentials.js
CHANGED
|
@@ -61,6 +61,48 @@ export function clearCredentials() {
|
|
|
61
61
|
return false;
|
|
62
62
|
}
|
|
63
63
|
}
|
|
64
|
+
const INFLIGHT_FILE = path.join(DIR, "refresh-inflight.json");
|
|
65
|
+
/**
|
|
66
|
+
* Deja constancia en disco de que ALGUIEN va a gastar el refresh token.
|
|
67
|
+
*
|
|
68
|
+
* Supabase lo rota **al recibir la peticion**, asi que entre "sale la peticion"
|
|
69
|
+
* y "guardamos el token nuevo" hay una ventana en la que el token del disco ya
|
|
70
|
+
* esta muerto sin que nadie lo sepa. Si el proceso no sobrevive a esa ventana
|
|
71
|
+
* —el `analyze` post-commit corre desacoplado en segundo plano, y basta con
|
|
72
|
+
* apagar el equipo o matar la terminal— el siguiente arranque reenvia el viejo,
|
|
73
|
+
* Supabase lo trata como robo y cierra la sesion.
|
|
74
|
+
*
|
|
75
|
+
* Esta marca no PUEDE evitarlo: nada del lado del cliente sobrevive a un
|
|
76
|
+
* `kill -9`. Lo que evita es que sea inexplicable. Sin ella, el usuario ve
|
|
77
|
+
* "Invalid Refresh Token: Already Used" y no tiene forma de saber por que; con
|
|
78
|
+
* ella se le puede decir cuando se quedo a medias la renovacion.
|
|
79
|
+
*/
|
|
80
|
+
export function markRefreshInFlight(now = new Date()) {
|
|
81
|
+
try {
|
|
82
|
+
fs.mkdirSync(DIR, { recursive: true, mode: 0o700 });
|
|
83
|
+
fs.writeFileSync(INFLIGHT_FILE, JSON.stringify({ at: now.toISOString(), pid: process.pid }), { mode: 0o600 });
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
// Es un diagnostico, no una garantia: si no se puede escribir, seguimos.
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
export function clearRefreshInFlight() {
|
|
90
|
+
try {
|
|
91
|
+
fs.rmSync(INFLIGHT_FILE, { force: true });
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
// idem
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
export function readRefreshInFlight() {
|
|
98
|
+
try {
|
|
99
|
+
const data = JSON.parse(fs.readFileSync(INFLIGHT_FILE, "utf8"));
|
|
100
|
+
return typeof data.at === "string" ? data : null;
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
64
106
|
const LOCK_FILE = path.join(DIR, "refresh.lock");
|
|
65
107
|
function sleep(ms) {
|
|
66
108
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
package/dist/git.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* normalization live here — a drift between them would make the same commit
|
|
5
5
|
* compress differently across subcommands and break the server-side dedup.
|
|
6
6
|
*/
|
|
7
|
-
import { execFile } from "node:child_process";
|
|
7
|
+
import { execFile, execFileSync } from "node:child_process";
|
|
8
8
|
import * as path from "node:path";
|
|
9
9
|
import { promisify } from "node:util";
|
|
10
10
|
export const execFileAsync = promisify(execFile);
|
|
@@ -60,12 +60,44 @@ export function usableSummary(message) {
|
|
|
60
60
|
return (lastBreak > MIN_USEFUL_SUMMARY_CHARS ? cut.slice(0, lastBreak) : cut).trim();
|
|
61
61
|
}
|
|
62
62
|
/** `git show <hash> -U0` — the unified diff of a single commit, no context. */
|
|
63
|
+
/**
|
|
64
|
+
* Ficheros que ESCRIBE el propio ChangeBook y que por tanto no puede analizar.
|
|
65
|
+
*
|
|
66
|
+
* `sync` regenera el bloque del mapa dentro de CLAUDE.md/AGENTS.md, y `analyze`
|
|
67
|
+
* lo dispara en cada commit. Sin excluirlos se cierra un bucle: commiteas el
|
|
68
|
+
* mapa regenerado -> el análisis lo ve -> nace/actualiza el módulo "Mapeo de
|
|
69
|
+
* módulos" -> pasa a ser el más reciente -> el mapa se reordena -> vuelve a
|
|
70
|
+
* estar sucio. Cuesta un diff de una línea cada dos por tres y el 2026-07-25
|
|
71
|
+
* costó un conflicto de merge real.
|
|
72
|
+
*
|
|
73
|
+
* No es una preferencia de ruido: una herramienta que se mide a sí misma
|
|
74
|
+
* inventa actividad que no existe. Los 6 "cambios" del módulo del mapa son
|
|
75
|
+
* exactamente eso.
|
|
76
|
+
*
|
|
77
|
+
* Un commit que SOLO toca estos ficheros produce un diff vacío y `analyze` lo
|
|
78
|
+
* salta con su mensaje de siempre — que es la respuesta correcta: regenerar el
|
|
79
|
+
* mapa no es un cambio de producto.
|
|
80
|
+
*/
|
|
81
|
+
export const FICHEROS_GENERADOS = [
|
|
82
|
+
":(exclude)CLAUDE.md",
|
|
83
|
+
":(exclude)AGENTS.md",
|
|
84
|
+
":(exclude)**/CLAUDE.md",
|
|
85
|
+
":(exclude)**/AGENTS.md",
|
|
86
|
+
];
|
|
63
87
|
export async function commitDiff(cwd, hash) {
|
|
64
88
|
try {
|
|
65
89
|
const { stdout } = await execFileAsync("git",
|
|
66
90
|
// --end-of-options so a ref that starts with "-" (e.g. "-n5") is treated
|
|
67
91
|
// as a revision, never as a git option (argument injection).
|
|
68
|
-
[
|
|
92
|
+
[
|
|
93
|
+
"show",
|
|
94
|
+
"--pretty=format:",
|
|
95
|
+
"-U0",
|
|
96
|
+
"--end-of-options",
|
|
97
|
+
hash,
|
|
98
|
+
"--",
|
|
99
|
+
...FICHEROS_GENERADOS,
|
|
100
|
+
], { cwd, encoding: "utf8", maxBuffer: GIT_MAX_BUFFER_BYTES });
|
|
69
101
|
return stdout;
|
|
70
102
|
}
|
|
71
103
|
catch (error) {
|
|
@@ -82,4 +114,47 @@ export function gitErrorMessage(error) {
|
|
|
82
114
|
}
|
|
83
115
|
return error instanceof Error ? error.message : String(error);
|
|
84
116
|
}
|
|
117
|
+
/**
|
|
118
|
+
* El nombre del proyecto al que reportar desde `dir`.
|
|
119
|
+
*
|
|
120
|
+
* NACE DE UN FLECO REAL. El 2026-07-25 trabajé en un worktree llamado `seo-wt` y
|
|
121
|
+
* el atlas creó un proyecto `seo-wt` en la cuenta de Raúl: gastó un análisis y
|
|
122
|
+
* dejó 4 módulos huérfanos en su selector de proyectos. No fue un bug del
|
|
123
|
+
* servidor: los cuatro sitios del CLI que deducen el nombre lo sacaban de
|
|
124
|
+
* `path.basename(cwd)`, y en un worktree eso NO es el nombre del repo.
|
|
125
|
+
*
|
|
126
|
+
* Un worktree enlazado es el MISMO repositorio: sus commits van al mismo sitio y
|
|
127
|
+
* su historia es la misma. Reportar a otro proyecto parte el atlas en dos por un
|
|
128
|
+
* detalle de cómo tienes montado el disco, y encima en silencio.
|
|
129
|
+
*
|
|
130
|
+
* Orden: `CHANGEBOOK_PROJECT` manda siempre (quien quiera un proyecto aparte por
|
|
131
|
+
* worktree lo dice y ya); si no, el nombre del árbol PRINCIPAL; y si no se puede
|
|
132
|
+
* averiguar, el basename de siempre.
|
|
133
|
+
*
|
|
134
|
+
* Se detecta comparando `--git-dir` con `--git-common-dir`: en el árbol principal
|
|
135
|
+
* son el mismo; en un worktree enlazado el primero es `.git/worktrees/<nombre>`.
|
|
136
|
+
*/
|
|
137
|
+
export function projectNameFor(dir, env = process.env) {
|
|
138
|
+
const explicito = env.CHANGEBOOK_PROJECT?.trim();
|
|
139
|
+
if (explicito)
|
|
140
|
+
return explicito;
|
|
141
|
+
const base = path.basename(path.resolve(dir));
|
|
142
|
+
try {
|
|
143
|
+
const gitDir = execFileSync("git", ["rev-parse", "--absolute-git-dir"], {
|
|
144
|
+
cwd: dir, encoding: "utf8", timeout: 5_000,
|
|
145
|
+
}).trim();
|
|
146
|
+
const comun = execFileSync("git", ["rev-parse", "--path-format=absolute", "--git-common-dir"], {
|
|
147
|
+
cwd: dir, encoding: "utf8", timeout: 5_000,
|
|
148
|
+
}).trim();
|
|
149
|
+
if (!gitDir || !comun || gitDir === comun)
|
|
150
|
+
return base;
|
|
151
|
+
// Worktree enlazado: el árbol principal es el padre del git-dir común.
|
|
152
|
+
const principal = path.basename(path.dirname(comun));
|
|
153
|
+
return principal || base;
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
// Sin git, o con una versión que no soporta estas banderas: como siempre.
|
|
157
|
+
return base;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
85
160
|
//# sourceMappingURL=git.js.map
|