@tekyzinc/gsd-t 5.18.10 → 5.19.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/CHANGELOG.md +47 -0
- package/README.md +1 -1
- package/bin/gsd-t-code-search-classifier.cjs +318 -0
- package/bin/gsd-t-pick-worktree.cjs +58 -23
- package/bin/gsd-t.js +117 -2
- package/commands/cpua.md +9 -1
- package/package.json +1 -1
- package/scripts/gsd-t-graph-search-guard.js +490 -0
- package/scripts/gsd-t-graph-use-report.js +179 -0
- package/templates/CLAUDE-global.md +3 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,53 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to GSD-T are documented here. Updated with each release.
|
|
4
4
|
|
|
5
|
+
## [5.19.10] - 2026-09-09
|
|
6
|
+
|
|
7
|
+
### Added — M117 Graph Search Guard: the graph rule finally fires on the path work actually takes
|
|
8
|
+
|
|
9
|
+
The rule "read code structure through the graph, never grep around it" had three enforcement
|
|
10
|
+
points and all three missed. The Grep-tool hook and Read-tool hook never fire, because
|
|
11
|
+
bypass-permissions mode routes every search through Bash. The runtime use-gate only runs inside
|
|
12
|
+
`gsd-t verify`, so a plain conversation was never measured. The ledger recorded the result:
|
|
13
|
+
2 grep events in three months, both June test probes, against 34,418 graph queries all issued by
|
|
14
|
+
the graph's own tooling. Not one came from a session choosing to consult it.
|
|
15
|
+
|
|
16
|
+
- `scripts/gsd-t-graph-search-guard.js`: a PreToolUse hook on Bash and Grep. Three outcomes and no
|
|
17
|
+
fourth — a structural code search BLOCKS with the graph command to run instead; a search over
|
|
18
|
+
content the graph does not index (`.md`, `.json`, `.sql`, config, prose) RUNS, because the graph
|
|
19
|
+
holds no answer to route to; a search that cannot be classified BLOCKS. No fail-open, no bypass
|
|
20
|
+
env var. A missing or unbuilt graph BLOCKS with `gsd-t graph index`, never a quiet fall back to
|
|
21
|
+
grep — that fallback is what let a project grep its way through 827 files with the graph unbuilt.
|
|
22
|
+
- `bin/gsd-t-code-search-classifier.cjs`: the three-way classifier. Deliberately NOT merged with
|
|
23
|
+
`gsd-t-grep-classifier.cjs` — that one feeds a hook that replaces grep output, so unsure means
|
|
24
|
+
"let grep run"; this one feeds a guard that blocks, so unsure means block. One module cannot hold
|
|
25
|
+
both defaults, and merging them would silently pick one caller's behaviour for both.
|
|
26
|
+
- `scripts/gsd-t-graph-use-report.js`: a Stop hook covering what no pattern-matcher can see — a turn
|
|
27
|
+
that answered a structural question by reading files end to end. It reports rather than blocks,
|
|
28
|
+
because a Stop hook fires after the work is done.
|
|
29
|
+
- Both hooks registered by `gsd-t install`; the classifier ships in both bin registries.
|
|
30
|
+
- `configureWriteEditHook` generalized to `configurePreToolUseHook` with the matcher as a parameter,
|
|
31
|
+
rather than copied for a Bash|Grep variant.
|
|
32
|
+
- 17 tests in `test/m117-graph-search-guard.test.js`, including the six real searches from the
|
|
33
|
+
session that prompted this, each of which ran unchallenged at the time.
|
|
34
|
+
|
|
35
|
+
## [5.18.11] - 2026-09-07
|
|
36
|
+
|
|
37
|
+
### Fixed — `gsd-t pick-worktree --name main` refused when the main checkout sat on a feature branch
|
|
38
|
+
|
|
39
|
+
The launcher prompt offers `"main" = work in main`, but the picker only treated a name as
|
|
40
|
+
"stay here" when it equalled the branch currently checked out. A main checkout left on
|
|
41
|
+
`feat/m26-file-mgmt` (NiceNote) turned `main` into `git worktree add -b main`, which git refused:
|
|
42
|
+
`a branch named 'main' already exists`. The person typing `main` means the FOLDER, not the branch.
|
|
43
|
+
|
|
44
|
+
- `bin/gsd-t-pick-worktree.cjs`: a typed name means the main checkout when it equals EITHER the branch
|
|
45
|
+
checked out there OR the repo's default branch as the remote declares it (`origin/HEAD` → `main`);
|
|
46
|
+
nothing is inferred from the name, so a `trunk` repo with no remote still treats `main` as a new
|
|
47
|
+
branch. The stderr notice names the branch actually checked out.
|
|
48
|
+
- Same class, other half: naming an EXISTING branch that is checked out nowhere now checks it out
|
|
49
|
+
into a worktree (`git worktree add <dest> <branch>`) instead of asking git to create it again.
|
|
50
|
+
- 2 regression tests in `test/m111-pick-worktree.test.js`.
|
|
51
|
+
|
|
5
52
|
## [5.18.10] - 2026-09-03
|
|
6
53
|
|
|
7
54
|
### Added — M115 Test-Plan-First Requirements Interrogation (`/gsd-t-test-plan`)
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# GSD-T: Contract-Driven Development for Claude Code
|
|
2
2
|
|
|
3
|
-
**v5.
|
|
3
|
+
**v5.19.10** - A methodology for reliable, parallelizable development using Claude Code with optional Agent Teams support.
|
|
4
4
|
|
|
5
5
|
**Eliminates context rot** — task-level fresh dispatch (one subagent per task, ~10-20% context each) means compaction never triggers.
|
|
6
6
|
**Compaction-proof debug loops** — `gsd-t headless --debug-loop` runs test-fix-retest cycles as separate `claude -p` sessions. A JSONL debug ledger persists all hypothesis/fix/learning history across fresh sessions. Anti-repetition preamble injection prevents retrying failed hypotheses. Escalation tiers (sonnet → opus → human) and a hard iteration ceiling enforced externally.
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* gsd-t-code-search-classifier.cjs
|
|
3
|
+
*
|
|
4
|
+
* M117 - Decide what a shell search command is asking for, so the search guard
|
|
5
|
+
* can stop the ones the code graph should answer.
|
|
6
|
+
*
|
|
7
|
+
* THIS IS NOT gsd-t-grep-classifier.cjs, AND THE DIFFERENCE IS THE POINT.
|
|
8
|
+
* That one feeds a hook that REPLACES grep output, so an unsure answer there
|
|
9
|
+
* means "let grep run" - a wrong guess only costs a missed opportunity. This
|
|
10
|
+
* one feeds a guard that BLOCKS, and under the graph rule an unsure answer is
|
|
11
|
+
* the dangerous direction: a search that proceeds because the classifier could
|
|
12
|
+
* not decide is a search that continued past a failure, which is the fallback
|
|
13
|
+
* the No-Fallback rule bans. So the defaults are inverted here on purpose.
|
|
14
|
+
* Merging the two would force one default onto both callers and silently break
|
|
15
|
+
* whichever one it was not chosen for.
|
|
16
|
+
*
|
|
17
|
+
* Three answers, no fourth:
|
|
18
|
+
*
|
|
19
|
+
* 'structural' asking who calls / who imports / where something is defined,
|
|
20
|
+
* over code. The graph answers this. BLOCK.
|
|
21
|
+
* 'content' searching text the graph does not index - markdown, JSON,
|
|
22
|
+
* SQL, config, prose, comments, log output. ALLOW. This is not
|
|
23
|
+
* a loophole: the graph has no answer, so there is nothing to
|
|
24
|
+
* route to.
|
|
25
|
+
* 'unclear' cannot tell. BLOCK, and say so. Never guessed either way.
|
|
26
|
+
*
|
|
27
|
+
* [RULE] search-classifier-unclear-blocks-never-allows
|
|
28
|
+
* [RULE] search-classifier-content-scope-is-what-graph-cannot-index
|
|
29
|
+
* [RULE] search-classifier-missing-input-is-unclear-not-content
|
|
30
|
+
*
|
|
31
|
+
* Zero dependencies. Pure - no filesystem, no spawning, no environment reads.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
'use strict';
|
|
35
|
+
|
|
36
|
+
// --- What the graph indexes ------------------------------------------------
|
|
37
|
+
// A search restricted to files OUTSIDE this set is a content search by
|
|
38
|
+
// definition: the graph holds nothing about them, so it cannot be the better
|
|
39
|
+
// answer. Kept as one shared constant because a second copy is where a
|
|
40
|
+
// mismatch hides.
|
|
41
|
+
const CODE_EXT = new Set([
|
|
42
|
+
'.js', '.cjs', '.mjs', '.jsx', '.ts', '.tsx', '.mts', '.cts',
|
|
43
|
+
'.py', '.pyi', '.go', '.rs', '.java', '.rb', '.php',
|
|
44
|
+
'.c', '.h', '.cc', '.cpp', '.hpp', '.cs', '.swift', '.kt', '.scala',
|
|
45
|
+
]);
|
|
46
|
+
|
|
47
|
+
// Content the graph does not index, named explicitly so "it is a .md file" is a
|
|
48
|
+
// decision rather than an absence of evidence.
|
|
49
|
+
const CONTENT_EXT = new Set([
|
|
50
|
+
'.md', '.markdown', '.txt', '.rst',
|
|
51
|
+
'.json', '.jsonl', '.yaml', '.yml', '.toml', '.ini', '.cfg', '.conf', '.env',
|
|
52
|
+
'.sql', '.csv', '.tsv',
|
|
53
|
+
'.sh', '.bash', '.zsh', '.fish',
|
|
54
|
+
'.html', '.css', '.scss', '.less',
|
|
55
|
+
'.lock', '.log', '.xml', '.svg',
|
|
56
|
+
]);
|
|
57
|
+
|
|
58
|
+
// The search programs this guard governs. `find` is here for `-name`, which is
|
|
59
|
+
// a "where does this live" question the graph answers.
|
|
60
|
+
const SEARCH_PROGRAMS = new Set(['grep', 'egrep', 'fgrep', 'rg', 'ripgrep', 'ag', 'ack', 'find', 'ugrep']);
|
|
61
|
+
|
|
62
|
+
// Flags whose NEXT argument names a file scope.
|
|
63
|
+
const SCOPE_FLAGS = new Set(['--include', '--glob', '-g', '--name', '-name', '-iname']);
|
|
64
|
+
|
|
65
|
+
// Flags of the form --include=<glob>, carrying the scope inline.
|
|
66
|
+
const INLINE_SCOPE_FLAGS = ['--include=', '--glob='];
|
|
67
|
+
|
|
68
|
+
// A bare identifier - a function, class, or variable name and nothing else.
|
|
69
|
+
const BARE_IDENT = /^[A-Za-z_$][A-Za-z0-9_$]{1,79}$/;
|
|
70
|
+
|
|
71
|
+
// A member access or call shape: Obj.method, this.method, foo(
|
|
72
|
+
const MEMBER_OR_CALL = /^[A-Za-z_$][A-Za-z0-9_$]*(?:\.[A-Za-z_$][A-Za-z0-9_$]*)?\(?$/;
|
|
73
|
+
|
|
74
|
+
// Declaration-hunting shapes. These ask where something is DEFINED.
|
|
75
|
+
const DECLARATION_SHAPES = [
|
|
76
|
+
/^(?:async\s+)?function\s+[A-Za-z_$]/,
|
|
77
|
+
/^class\s+[A-Za-z_$]/,
|
|
78
|
+
/^def\s+[A-Za-z_$]/,
|
|
79
|
+
/^(?:export\s+)?(?:const|let|var)\s+[A-Za-z_$]/,
|
|
80
|
+
/^func\s+[A-Za-z_$]/,
|
|
81
|
+
/^(?:pub\s+)?fn\s+[A-Za-z_$]/,
|
|
82
|
+
];
|
|
83
|
+
|
|
84
|
+
// Import/require shapes - "who depends on this".
|
|
85
|
+
const IMPORT_SHAPES = [
|
|
86
|
+
/\bimport\s/,
|
|
87
|
+
/\bfrom\s+\S+\s+import\b/,
|
|
88
|
+
/\brequire\s*\(/,
|
|
89
|
+
/\bexport\s+(?:default\s+)?(?:function|class|const)\b/,
|
|
90
|
+
];
|
|
91
|
+
|
|
92
|
+
// Multi-word English: an error message or a sentence, not a symbol.
|
|
93
|
+
const PROSE_SHAPE = /^[A-Za-z][A-Za-z'-]*(?:\s+[A-Za-z][A-Za-z'-]*)+$/;
|
|
94
|
+
|
|
95
|
+
// A run of characters shaped like an identifier.
|
|
96
|
+
const IDENTIFIER_RUN = /[A-Za-z_$][A-Za-z0-9_$]{2,}/;
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Pull the file extensions a search is scoped to, from its arguments.
|
|
100
|
+
* Returns a Set; empty means "no scope stated".
|
|
101
|
+
*/
|
|
102
|
+
function scopedExtensions(argv) {
|
|
103
|
+
const found = new Set();
|
|
104
|
+
|
|
105
|
+
const addFrom = (s) => {
|
|
106
|
+
if (typeof s !== 'string') return;
|
|
107
|
+
// *.{js,ts} - a brace group naming several at once.
|
|
108
|
+
const brace = s.match(/\*\.\{([^}]+)\}/);
|
|
109
|
+
if (brace) {
|
|
110
|
+
for (const part of brace[1].split(',')) {
|
|
111
|
+
const e = '.' + part.trim().replace(/^\./, '');
|
|
112
|
+
if (e.length > 1) found.add(e.toLowerCase());
|
|
113
|
+
}
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
// *.md, path/to/file.json
|
|
117
|
+
const m = s.match(/\.([A-Za-z0-9]+)$/);
|
|
118
|
+
if (m) found.add(('.' + m[1]).toLowerCase());
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const inlineScopeValue = (arg) => {
|
|
122
|
+
for (const prefix of INLINE_SCOPE_FLAGS) {
|
|
123
|
+
if (arg.startsWith(prefix)) return arg.slice(prefix.length);
|
|
124
|
+
}
|
|
125
|
+
return null;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
for (let i = 0; i < argv.length; i++) {
|
|
129
|
+
const a = argv[i];
|
|
130
|
+
if (typeof a !== 'string') continue;
|
|
131
|
+
|
|
132
|
+
if (SCOPE_FLAGS.has(a)) {
|
|
133
|
+
addFrom(argv[i + 1]);
|
|
134
|
+
i++;
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const inline = inlineScopeValue(a);
|
|
139
|
+
if (inline !== null) {
|
|
140
|
+
addFrom(inline);
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ripgrep -t<type> shorthand: -tmd, -tjs. Counted only when the type names
|
|
145
|
+
// an extension we know; an unknown type states no scope.
|
|
146
|
+
const typeShorthand = a.match(/^-t([a-z]+)$/);
|
|
147
|
+
if (typeShorthand) {
|
|
148
|
+
const asExt = '.' + typeShorthand[1];
|
|
149
|
+
if (CODE_EXT.has(asExt)) found.add(asExt);
|
|
150
|
+
else if (CONTENT_EXT.has(asExt)) found.add(asExt);
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (!a.startsWith('-')) addFrom(a);
|
|
155
|
+
}
|
|
156
|
+
return found;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Is every extension this search touches outside the graph's index?
|
|
161
|
+
* Only true when a scope was actually stated - an unscoped search reaches code
|
|
162
|
+
* whether or not the pattern looks like prose.
|
|
163
|
+
*/
|
|
164
|
+
function scopedEntirelyToContent(exts) {
|
|
165
|
+
if (exts.size === 0) return false;
|
|
166
|
+
for (const e of exts) {
|
|
167
|
+
if (!CONTENT_EXT.has(e)) return false;
|
|
168
|
+
}
|
|
169
|
+
return true;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function touchesCode(exts) {
|
|
173
|
+
for (const e of exts) {
|
|
174
|
+
if (CODE_EXT.has(e)) return true;
|
|
175
|
+
}
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function asksByFileName(argv) {
|
|
180
|
+
for (const a of argv) {
|
|
181
|
+
if (a === '-name') return true;
|
|
182
|
+
if (a === '-iname') return true;
|
|
183
|
+
}
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Classify a search.
|
|
189
|
+
*
|
|
190
|
+
* @param {object} input
|
|
191
|
+
* @param {string} input.pattern what is being searched for
|
|
192
|
+
* @param {string[]} [input.argv] the rest of the command's arguments
|
|
193
|
+
* @param {string} [input.program] grep | rg | find | ...
|
|
194
|
+
* @returns {{ verdict:'structural'|'content'|'unclear',
|
|
195
|
+
* reason:string, symbol:string|null, verb:string|null }}
|
|
196
|
+
*/
|
|
197
|
+
function classifySearch(input) {
|
|
198
|
+
// A caller that hands over no pattern has not asked a question this can
|
|
199
|
+
// answer. Reporting 'content' would ALLOW the search on the strength of an
|
|
200
|
+
// input that never arrived - deciding by absence of evidence. It is unclear,
|
|
201
|
+
// and unclear blocks.
|
|
202
|
+
if (!input) return unclear('no input was given');
|
|
203
|
+
if (typeof input.pattern !== 'string') return unclear('no search pattern was given');
|
|
204
|
+
const pattern = input.pattern.trim();
|
|
205
|
+
if (!pattern) return unclear('the search pattern was empty');
|
|
206
|
+
|
|
207
|
+
const argv = Array.isArray(input.argv) ? input.argv : [];
|
|
208
|
+
const program = typeof input.program === 'string' ? input.program : 'grep';
|
|
209
|
+
|
|
210
|
+
const exts = scopedExtensions(argv);
|
|
211
|
+
|
|
212
|
+
// Scoped entirely to things the graph does not index. The graph has no
|
|
213
|
+
// answer, so there is nothing to route to.
|
|
214
|
+
if (scopedEntirelyToContent(exts)) {
|
|
215
|
+
return {
|
|
216
|
+
verdict: 'content',
|
|
217
|
+
reason: 'scoped to ' + [...exts].join(', ') + ' - the graph does not index these',
|
|
218
|
+
symbol: null,
|
|
219
|
+
verb: null,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// `find -name Foo.js` asks where a file lives. The graph knows.
|
|
224
|
+
if (program === 'find' && asksByFileName(argv) && touchesCode(exts)) {
|
|
225
|
+
return {
|
|
226
|
+
verdict: 'structural',
|
|
227
|
+
reason: 'locating a code file by name',
|
|
228
|
+
symbol: pattern.replace(/^\*/, '').replace(/\*$/, ''),
|
|
229
|
+
verb: 'defines',
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Unmistakable prose: several English words. The graph indexes symbols and
|
|
234
|
+
// edges, not sentences.
|
|
235
|
+
if (PROSE_SHAPE.test(pattern) && pattern.split(/\s+/).length >= 3) {
|
|
236
|
+
return { verdict: 'content', reason: 'a phrase, not a symbol', symbol: null, verb: null };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// A quoted string literal hunted through the codebase is content.
|
|
240
|
+
if (/^["'].*["']$/.test(pattern) && pattern.length > 12) {
|
|
241
|
+
return { verdict: 'content', reason: 'a string literal', symbol: null, verb: null };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// --- Structural shapes ---------------------------------------------------
|
|
245
|
+
for (const re of IMPORT_SHAPES) {
|
|
246
|
+
if (re.test(pattern)) {
|
|
247
|
+
return {
|
|
248
|
+
verdict: 'structural',
|
|
249
|
+
reason: 'asking who imports or requires something',
|
|
250
|
+
symbol: extractSymbol(pattern),
|
|
251
|
+
verb: 'who-imports',
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
for (const re of DECLARATION_SHAPES) {
|
|
257
|
+
if (re.test(pattern)) {
|
|
258
|
+
return {
|
|
259
|
+
verdict: 'structural',
|
|
260
|
+
reason: 'asking where something is defined',
|
|
261
|
+
symbol: extractSymbol(pattern),
|
|
262
|
+
verb: 'defines',
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (BARE_IDENT.test(pattern)) {
|
|
268
|
+
return {
|
|
269
|
+
verdict: 'structural',
|
|
270
|
+
reason: 'a bare symbol name - who calls it and who imports it is a graph question',
|
|
271
|
+
symbol: pattern,
|
|
272
|
+
verb: 'who-calls',
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (MEMBER_OR_CALL.test(pattern)) {
|
|
277
|
+
return {
|
|
278
|
+
verdict: 'structural',
|
|
279
|
+
reason: 'a call or member-access shape',
|
|
280
|
+
symbol: pattern.replace(/\($/, ''),
|
|
281
|
+
verb: 'who-calls',
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// --- Everything left -----------------------------------------------------
|
|
286
|
+
// A regex, a mixed pattern, something with punctuation. It MIGHT be a text
|
|
287
|
+
// search and it might be a symbol hunt wearing a regex. Saying "probably
|
|
288
|
+
// text" here is the guess this classifier exists not to make.
|
|
289
|
+
//
|
|
290
|
+
// One narrowing first: a pattern holding no identifier-shaped run has nothing
|
|
291
|
+
// the graph could be asked about, whatever else it is.
|
|
292
|
+
if (!IDENTIFIER_RUN.test(pattern)) {
|
|
293
|
+
return {
|
|
294
|
+
verdict: 'content',
|
|
295
|
+
reason: 'no symbol-shaped text in the pattern',
|
|
296
|
+
symbol: null,
|
|
297
|
+
verb: null,
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
return unclear('could be a symbol hunt or a text search - it has to be said which');
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function unclear(reason) {
|
|
305
|
+
return { verdict: 'unclear', reason, symbol: null, verb: null };
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Pull the most likely symbol out of a declaration/import pattern, for the
|
|
309
|
+
// suggested graph command. Null when nothing identifier-shaped is present.
|
|
310
|
+
function extractSymbol(pattern) {
|
|
311
|
+
const trailing = pattern.match(/([A-Za-z_$][A-Za-z0-9_$]{1,})\s*$/);
|
|
312
|
+
if (trailing) return trailing[1];
|
|
313
|
+
const anywhere = pattern.match(/([A-Za-z_$][A-Za-z0-9_$]{1,})/);
|
|
314
|
+
if (anywhere) return anywhere[1];
|
|
315
|
+
return null;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
module.exports = { classifySearch, CODE_EXT, CONTENT_EXT, SEARCH_PROGRAMS };
|
|
@@ -102,17 +102,20 @@ function main() {
|
|
|
102
102
|
}
|
|
103
103
|
|
|
104
104
|
if (wanted) {
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
|
|
105
|
+
// Naming the main checkout means "work here" — not "make a worktree called
|
|
106
|
+
// main", which git refuses anyway. The main checkout answers to two names:
|
|
107
|
+
// the branch it is sitting on right now, and the repo's default branch
|
|
108
|
+
// (`main` in a repo whose origin/HEAD is main), which is what the launcher
|
|
109
|
+
// prompt offers even when the checkout has wandered onto a feature branch.
|
|
110
|
+
// Rare, but it is a real choice, and the only way to express it is to name it.
|
|
111
|
+
if (meansMainCheckout(cwd, branchNameFrom(wanted))) {
|
|
110
112
|
// Said out loud on stderr: every other path here is silent, but this one
|
|
111
113
|
// is a deliberate choice to work somewhere the house rules steer away
|
|
112
114
|
// from, and silence would read as "the name was ignored". stdout stays
|
|
113
|
-
// empty because the shell reads it as the directory to move to.
|
|
115
|
+
// empty because the shell reads it as the directory to move to. The
|
|
116
|
+
// branch named is the one actually checked out, not the one typed.
|
|
114
117
|
process.stderr.write(
|
|
115
|
-
`[GSD-T WORKTREE] staying in the main checkout on ${
|
|
118
|
+
`[GSD-T WORKTREE] staying in the main checkout on ${currentBranch(cwd) || "(detached)"} — ` +
|
|
116
119
|
`no worktree created.\n`
|
|
117
120
|
);
|
|
118
121
|
stay();
|
|
@@ -138,25 +141,45 @@ function main() {
|
|
|
138
141
|
}
|
|
139
142
|
|
|
140
143
|
/**
|
|
141
|
-
*
|
|
144
|
+
* Does this name refer to the main checkout — the folder the session started in?
|
|
142
145
|
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
146
|
+
* Two names do: the branch checked out there right now, and the repo's default
|
|
147
|
+
* branch. The second matters because the main checkout is often left on a
|
|
148
|
+
* feature branch, and the person typing "main" at the launcher prompt means the
|
|
149
|
+
* FOLDER, not the branch — a worktree on `main` is exactly what git refuses.
|
|
147
150
|
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
+
* The default is asked of git (origin/HEAD), never assumed: a repo on `trunk`
|
|
152
|
+
* with no origin treats `main` as an ordinary new branch name. A repo git
|
|
153
|
+
* cannot answer for falls through to the ordinary worktree path, which fails
|
|
154
|
+
* loudly on its own if git is genuinely broken.
|
|
151
155
|
*/
|
|
152
|
-
function
|
|
156
|
+
function meansMainCheckout(repo, name) {
|
|
157
|
+
const typed = String(name).toLowerCase();
|
|
158
|
+
// Both sides lowercased: a branch name typed at a prompt is a value the user
|
|
159
|
+
// types, and "Main" must mean main.
|
|
160
|
+
const current = currentBranch(repo);
|
|
161
|
+
if (current && current.toLowerCase() === typed) return true;
|
|
162
|
+
const def = defaultBranch(repo);
|
|
163
|
+
return Boolean(def) && def.toLowerCase() === typed;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// The branch the main checkout sits on; "" when detached or git cannot say.
|
|
167
|
+
function currentBranch(repo) {
|
|
153
168
|
const r = spawnSync("git", ["branch", "--show-current"], {
|
|
154
169
|
cwd: repo, encoding: "utf8", timeout: 10000,
|
|
155
170
|
});
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
171
|
+
return r.status === 0 ? String(r.stdout || "").trim() : "";
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// The repo's default branch as the remote declares it (origin/HEAD → "main"),
|
|
175
|
+
// or null when there is no remote to ask. Nothing is inferred from the name.
|
|
176
|
+
function defaultBranch(repo) {
|
|
177
|
+
const r = spawnSync("git", ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"], {
|
|
178
|
+
cwd: repo, encoding: "utf8", timeout: 10000,
|
|
179
|
+
});
|
|
180
|
+
if (r.status !== 0) return null;
|
|
181
|
+
const ref = String(r.stdout || "").trim();
|
|
182
|
+
return ref.startsWith("origin/") ? ref.slice("origin/".length) : null;
|
|
160
183
|
}
|
|
161
184
|
|
|
162
185
|
// Turn what the user typed into a name git will accept, without silently
|
|
@@ -253,9 +276,13 @@ function enterOrCreate(repo, home, branch) {
|
|
|
253
276
|
|
|
254
277
|
fs.mkdirSync(home, { recursive: true });
|
|
255
278
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
279
|
+
// A branch that already exists (a feature branch from last week, nobody in
|
|
280
|
+
// it) is checked out as it is; `-b` would ask git to create it again, and git
|
|
281
|
+
// refuses. Only a name git has never seen becomes a new branch.
|
|
282
|
+
const args = localBranchExists(repo, branch)
|
|
283
|
+
? ["worktree", "add", dest, branch]
|
|
284
|
+
: ["worktree", "add", dest, "-b", branch];
|
|
285
|
+
const r = spawnSync("git", args, { cwd: repo, encoding: "utf8" });
|
|
259
286
|
|
|
260
287
|
// git writes progress to stderr even when it succeeds, so the exit code and
|
|
261
288
|
// the directory existing are what actually prove it worked.
|
|
@@ -272,6 +299,14 @@ function enterOrCreate(repo, home, branch) {
|
|
|
272
299
|
return { path: dest };
|
|
273
300
|
}
|
|
274
301
|
|
|
302
|
+
// Is there a local branch by this exact name? Asked of git's refs, not the
|
|
303
|
+
// worktree list, so a branch checked out nowhere still counts.
|
|
304
|
+
function localBranchExists(repo, branch) {
|
|
305
|
+
return spawnSync("git", ["rev-parse", "--verify", "--quiet", `refs/heads/${branch}`], {
|
|
306
|
+
cwd: repo, stdio: "pipe", timeout: 10000,
|
|
307
|
+
}).status === 0;
|
|
308
|
+
}
|
|
309
|
+
|
|
275
310
|
/**
|
|
276
311
|
* Does THIS repo know `dest` as its worktree for `branch`?
|
|
277
312
|
*
|