pi-orchestrator-config 4.7.3 → 4.8.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.
- package/README.md +6 -1
- package/agents/conflict-resolver.md +37 -0
- package/agents/git-expert.md +23 -1
- package/extensions/orchestrator/enforcement-helpers.ts +532 -18
- package/extensions/orchestrator/enforcement.ts +98 -0
- package/extensions/orchestrator/git-helpers.ts +742 -0
- package/extensions/pidiff/pidiff-ui/package.json +1 -1
- package/package.json +1 -1
- package/prompts/review-handler.md +14 -1
- package/rules/10-agent-routing.md +2 -0
- package/rules/35-memory.md +6 -1
- package/rules/40-critical-rules.md +8 -1
- package/rules/50-agent-bug-reporting.md +1 -0
- package/skills/conflict-resolver/SKILL.md +7 -4
package/README.md
CHANGED
|
@@ -48,12 +48,17 @@ Single extension that provides:
|
|
|
48
48
|
| -------------- | --------------------------------------------------------------------------------------------------------------- |
|
|
49
49
|
| Languages | python-expert, go-expert, ts-expert, java-expert, bash-expert |
|
|
50
50
|
| Infrastructure | docker-expert, kubernetes-expert, jenkins-expert |
|
|
51
|
-
| Dev workflow | git-expert, github-expert, test-runner, test-automator, debugger
|
|
51
|
+
| Dev workflow | git-expert, github-expert, conflict-resolver, test-runner, test-automator, debugger |
|
|
52
52
|
| Documentation | technical-documentation-writer, api-documenter, docs-fetcher |
|
|
53
53
|
| Code review | code-reviewer-quality, code-reviewer-guidelines, code-reviewer-security, code-reviewer-docs, code-reviewer-spec |
|
|
54
54
|
| Security | security-auditor |
|
|
55
55
|
| Workflow | scout, planner, worker, reviewer |
|
|
56
56
|
|
|
57
|
+
`git-expert` cannot resolve a conflict — it is blocked from staging a side of one by
|
|
58
|
+
enforcement, not just by prompt. When a merge, rebase or cherry-pick stops with unresolved
|
|
59
|
+
files, delegate to `conflict-resolver`, which resolves them and hands the staged result back
|
|
60
|
+
for `git-expert` to commit.
|
|
61
|
+
|
|
57
62
|
### Prompt Templates
|
|
58
63
|
|
|
59
64
|
| Prompt | Description |
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: conflict-resolver
|
|
3
|
+
description: Resolve git merge, rebase, and cherry-pick conflicts by reading commit intent on both sides, where git-expert is blocked from resolving them and hands off instead.
|
|
4
|
+
tools: read, bash, edit, write
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
You resolve merge/rebase/cherry-pick conflicts. `git-expert` is blocked from doing this and
|
|
8
|
+
hands conflicts to you; `enforcement.ts` blocks `git add`, `git restore`, `git checkout
|
|
9
|
+
--ours/--theirs`, and `merge|rebase --continue` there while conflicts are unresolved.
|
|
10
|
+
|
|
11
|
+
Read the skill before touching a file, and resolve its path at runtime — an agent runs in the
|
|
12
|
+
target project, which usually has no `skills/` directory:
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
ls -1 ~/.pi/agent/npm/node_modules/pi-orchestrator-config/skills/conflict-resolver/SKILL.md \
|
|
16
|
+
2>/dev/null || ls -1 skills/conflict-resolver/SKILL.md 2>/dev/null
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
If neither path exists, work from the non-negotiables below; they are sufficient on their own.
|
|
20
|
+
|
|
21
|
+
- **Understand both sides first** — `git ls-files --unmerged`, then the commits each side brought.
|
|
22
|
+
Never resolve a file you have not read the history of.
|
|
23
|
+
- **`--ours`/`--theirs` are swapped during rebase.** Ours is upstream. If unsure, compare stages:
|
|
24
|
+
`git diff :2:<file> :3:<file>`.
|
|
25
|
+
- **Never pick a side for a lock file.** Resolve the manifest, delete the lock, regenerate
|
|
26
|
+
(`uv lock`, `npm install`).
|
|
27
|
+
- **Never commit.** Only `git-expert` may, and the harness rejects it here. Your job ends with the
|
|
28
|
+
working tree resolved and staged: `git add <resolved files>`, then report. Which sequencer the
|
|
29
|
+
caller continues depends on what was in progress — `git merge --continue`, `git rebase --continue`
|
|
30
|
+
or `git cherry-pick --continue` — so say which one you found in your report rather than assuming
|
|
31
|
+
rebase. The commit itself is also `git-expert`'s.
|
|
32
|
+
- **Never** force-push, reset away either side, or skip pre-commit hooks.
|
|
33
|
+
- Prefer merging both sides when the intents are compatible; a bug fix or security fix wins over
|
|
34
|
+
a feature; style-only conflicts take either side.
|
|
35
|
+
|
|
36
|
+
Finish by staging resolved files, running the project's tests for what you touched, and reporting
|
|
37
|
+
per file: which side won, why, and anything you could not confidently resolve.
|
package/agents/git-expert.md
CHANGED
|
@@ -88,6 +88,28 @@ Before ANY `git commit`, if `review_loop_enforcement` is `true`:
|
|
|
88
88
|
- NEVER use `--no-verify` flag
|
|
89
89
|
- Branch prefixes: `feature/`, `fix/`, `hotfix/`, `refactor/`
|
|
90
90
|
|
|
91
|
+
## Merge Conflicts
|
|
92
|
+
|
|
93
|
+
Do NOT resolve merge, rebase, or cherry-pick conflicts. After any of those commands, check:
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
git ls-files --unmerged
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
If it lists any file, STOP and report:
|
|
100
|
+
|
|
101
|
+
```text
|
|
102
|
+
⛔ Unresolved conflicts in <files>. Delegate to the conflict-resolver agent.
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Enforcement blocks `git add`, `git restore`, `git rm`, `git update-index`, `git reset`,
|
|
106
|
+
`git checkout --ours/--theirs`, `git merge --continue`, `git rebase --continue`, and
|
|
107
|
+
`git cherry-pick --continue` while conflicts are unresolved — you will not be able to resolve
|
|
108
|
+
here even if you try. A command that starts a merge, rebase, cherry-pick or stash and then
|
|
109
|
+
stages a file is blocked as well: the conflict does not exist yet, so the check cannot see it.
|
|
110
|
+
Back out with `git merge --abort`, `git rebase --abort`, or `git cherry-pick --abort` and let the
|
|
111
|
+
caller call `conflict-resolver`.
|
|
112
|
+
|
|
91
113
|
## Separation of Concerns
|
|
92
114
|
|
|
93
115
|
- This agent does NOT run tests or fix code.
|
|
@@ -128,4 +150,4 @@ Format rules:
|
|
|
128
150
|
## Scope
|
|
129
151
|
|
|
130
152
|
**Handles:** commit, branch, merge, rebase, stash, cherry-pick, log, diff, status, config
|
|
131
|
-
**Delegate:** PRs, issues, releases, workflows → `github-expert`
|
|
153
|
+
**Delegate:** merge/rebase/cherry-pick conflicts → `conflict-resolver`; PRs, issues, releases, workflows → `github-expert`
|
|
@@ -7,7 +7,7 @@ import { createLogger } from "../shared/logger.js";
|
|
|
7
7
|
import { realpathSync } from "node:fs";
|
|
8
8
|
import * as path from "node:path";
|
|
9
9
|
import { join } from "node:path";
|
|
10
|
-
import { DANGEROUS, getCurrentBranch, hasGitSub } from "./git-helpers.js";
|
|
10
|
+
import { DANGEROUS, executableText, getCurrentBranch, hasGitSub, isConflictResolutionCommand, segmentRunsGit, tokenize } from "./git-helpers.js";
|
|
11
11
|
|
|
12
12
|
const enfLog = createLogger("enforcement");
|
|
13
13
|
enfLog.debug("enforcement-helpers module loaded");
|
|
@@ -59,26 +59,540 @@ export function commandHasTrailerByName(command: string, trailerName: string): b
|
|
|
59
59
|
return result;
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
/**
|
|
63
|
+
* Split a command into segments on shell separators that are *outside* quotes,
|
|
64
|
+
* recording each segment's offset, its parenthesis depth, and whether the
|
|
65
|
+
* separator before it was `||` (so the segment may never run). Quoted text is
|
|
66
|
+
* data, not syntax: a `(` or `cd` inside a string is part of an argument, never
|
|
67
|
+
* a boundary.
|
|
68
|
+
*
|
|
69
|
+
* Depth and conditionality are what let the caller tell a `cd` that moves this
|
|
70
|
+
* shell from one inside a subshell or behind a short-circuit, which change
|
|
71
|
+
* nothing for a later command.
|
|
72
|
+
*/
|
|
73
|
+
interface ShellSegment {
|
|
74
|
+
text: string;
|
|
75
|
+
start: number;
|
|
76
|
+
depth: number;
|
|
77
|
+
conditional: boolean;
|
|
78
|
+
/** `cmd &` runs the command in a subshell, so its cd does not move this shell. */
|
|
79
|
+
backgrounded?: boolean;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function unquotedSegments(command: string): ShellSegment[] {
|
|
83
|
+
const segments: ShellSegment[] = [];
|
|
84
|
+
let start = 0;
|
|
85
|
+
let quote: string | null = null;
|
|
86
|
+
let depth = 0;
|
|
87
|
+
let conditional = false;
|
|
88
|
+
const push = (end: number, startAt: number, cond: boolean, backgrounded = false) => {
|
|
89
|
+
segments.push({
|
|
90
|
+
text: command.slice(startAt, end),
|
|
91
|
+
start: startAt,
|
|
92
|
+
depth,
|
|
93
|
+
conditional: cond,
|
|
94
|
+
...(backgrounded ? { backgrounded: true } : {}),
|
|
95
|
+
});
|
|
96
|
+
};
|
|
97
|
+
// The index of the first segment in the current `&&`/`||` chain. A trailing
|
|
98
|
+
// `&` backgrounds the whole chain, not just the command beside it.
|
|
99
|
+
let chainStart = 0;
|
|
100
|
+
const chainLen = () => segments.length;
|
|
101
|
+
for (let i = 0; i < command.length; i++) {
|
|
102
|
+
const ch = command[i];
|
|
103
|
+
if (quote) {
|
|
104
|
+
if (ch === quote) quote = null;
|
|
105
|
+
// A quote spans a newline: `cd "conflicted\nrepo"` is one multi-line path.
|
|
106
|
+
// An unterminated quote is not recovered from here either — bash reports
|
|
107
|
+
// one and runs nothing, and the caller fails closed when no directory
|
|
108
|
+
// can be resolved.
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (ch === "\\" && command[i + 1]) {
|
|
112
|
+
// An escaped character is part of a word: `weird\&name` is one directory
|
|
113
|
+
// name, not a name followed by a background operator.
|
|
114
|
+
i++;
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
if (ch === "'" || ch === '"') {
|
|
118
|
+
quote = ch;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
// A separator is a single "&&", "||", ";", "|", or newline; "(" and ")"
|
|
122
|
+
// bracket a subshell but are not separators on their own.
|
|
123
|
+
const two = command.slice(i, i + 2);
|
|
124
|
+
if (two === "&&" || two === "||") {
|
|
125
|
+
push(i, start, conditional);
|
|
126
|
+
// What FOLLOWS a short-circuit may be skipped: `false && cd x` never runs
|
|
127
|
+
// it, and `cd a || cd b` runs the second only if the first failed.
|
|
128
|
+
conditional = true;
|
|
129
|
+
i++;
|
|
130
|
+
start = i + 1;
|
|
131
|
+
} else if (ch === "\n") {
|
|
132
|
+
// An unterminated quote cannot survive a line break in practice; drop the
|
|
133
|
+
// state so one stray apostrophe cannot blind the directory parser for the
|
|
134
|
+
// rest of the command.
|
|
135
|
+
if (quote) {
|
|
136
|
+
enfLog.debug("quote_reset_at_newline", "char", quote);
|
|
137
|
+
quote = null;
|
|
138
|
+
}
|
|
139
|
+
push(i, start, conditional);
|
|
140
|
+
conditional = false;
|
|
141
|
+
start = i + 1;
|
|
142
|
+
chainStart = chainLen();
|
|
143
|
+
} else if (ch === ";") {
|
|
144
|
+
push(i, start, conditional);
|
|
145
|
+
conditional = false;
|
|
146
|
+
start = i + 1;
|
|
147
|
+
chainStart = chainLen();
|
|
148
|
+
} else if (ch === "|") {
|
|
149
|
+
// Every pipeline component runs in a subshell of its own, so a directory
|
|
150
|
+
// change on the left of the pipe does not reach the right. Marking it
|
|
151
|
+
// backgrounded is exactly the same rule as a trailing ampersand.
|
|
152
|
+
push(i, start, conditional, true);
|
|
153
|
+
conditional = false;
|
|
154
|
+
start = i + 1;
|
|
155
|
+
chainStart = chainLen();
|
|
156
|
+
} else if (ch === "&") {
|
|
157
|
+
// `2>&1` and `>&2` redirect; a lone `&` backgrounds the whole chain it
|
|
158
|
+
// ends, so every segment of that chain runs in a subshell.
|
|
159
|
+
const redirect = command[i - 1] === ">";
|
|
160
|
+
if (redirect) continue;
|
|
161
|
+
const isDouble = command[i + 1] === "&";
|
|
162
|
+
push(i, start, conditional, !isDouble);
|
|
163
|
+
if (!isDouble) {
|
|
164
|
+
for (let s = chainStart; s < segments.length; s++) segments[s].backgrounded = true;
|
|
165
|
+
}
|
|
166
|
+
conditional = false;
|
|
167
|
+
start = i + 1;
|
|
168
|
+
chainStart = chainLen();
|
|
169
|
+
} else if (ch === "(") {
|
|
170
|
+
depth++;
|
|
171
|
+
} else if (ch === ")") {
|
|
172
|
+
if (depth > 0) {
|
|
173
|
+
// The closing paren ends the segment it belongs to; only what follows
|
|
174
|
+
// it is back at the outer depth.
|
|
175
|
+
push(i, start, false);
|
|
176
|
+
depth--;
|
|
177
|
+
start = i + 1;
|
|
178
|
+
conditional = false;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
push(command.length, start, conditional);
|
|
183
|
+
enfLog.debug("shell_segments", "count", segments.length);
|
|
184
|
+
return segments;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** The target of a `cd` at the start of a segment, honouring quoting and `~`. */
|
|
188
|
+
function cdTargetIn(segment: string): string | null {
|
|
189
|
+
// A cd may follow a shell keyword or a builtin wrapper: `if cd /repo; then …`,
|
|
190
|
+
// `command cd /repo`.
|
|
191
|
+
const m = /^\s*(?:(?:[A-Za-z_][A-Za-z0-9_]*=\S*\s+)|if\s+|then\s+|else\s+|elif\s+|do\s+|\(\s*|\{\s*)*(?:(?:command|builtin)\s+)*(cd|pushd|popd)\b\s*([\s\S]*)$/.exec(
|
|
192
|
+
segment,
|
|
193
|
+
);
|
|
194
|
+
if (!m) {
|
|
195
|
+
enfLog.debug("cd_target_absent", "segment_head", segment.trim().slice(0, 24));
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
// `popd` returns to whatever `pushd` displaced, and takes no argument.
|
|
199
|
+
if (m[1] === "popd") return "~popd";
|
|
200
|
+
const args = m[2];
|
|
201
|
+
// Walk the arguments: skip options, then read one destination — a quoted
|
|
202
|
+
// target whole, so a directory name may contain spaces or parentheses.
|
|
203
|
+
let i = 0;
|
|
204
|
+
const skipSpace = () => { while (i < args.length && /\s/.test(args[i])) i++; };
|
|
205
|
+
for (;;) {
|
|
206
|
+
skipSpace();
|
|
207
|
+
if (i >= args.length) break;
|
|
208
|
+
if (args.startsWith("--", i)) { i += 2; continue; }
|
|
209
|
+
// A lone `-` is the previous directory, not an option.
|
|
210
|
+
if (args[i] === "-" && (i + 1 >= args.length || /\s/.test(args[i + 1]))) {
|
|
211
|
+
enfLog.debug("cd_target_previous");
|
|
212
|
+
return "-";
|
|
213
|
+
}
|
|
214
|
+
if (args[i] === "-" && i + 1 < args.length) {
|
|
215
|
+
while (i < args.length && !/\s/.test(args[i])) i++;
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
if (args[i] === "\\" && i + 1 < args.length) {
|
|
219
|
+
i += 2;
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
const quote = args[i];
|
|
223
|
+
if (quote === "'" || quote === '"') {
|
|
224
|
+
const end = args.indexOf(quote, i + 1);
|
|
225
|
+
let target = end === -1 ? args.slice(i + 1) : args.slice(i + 1, end);
|
|
226
|
+
i = end === -1 ? args.length : end + 1;
|
|
227
|
+
// The shell concatenates adjacent parts into one word: `cd "work tree"x`
|
|
228
|
+
// is `work treex`, so keep whatever follows the closing quote.
|
|
229
|
+
while (i < args.length && !/[\s;&|)]/.test(args[i])) {
|
|
230
|
+
target += args[i];
|
|
231
|
+
i++;
|
|
232
|
+
}
|
|
233
|
+
// Inside quotes a backslash is literal, so it is doubled here and the
|
|
234
|
+
// caller unescapes it back: `cd 'foo\ bar'` is one directory whose name
|
|
235
|
+
// contains a backslash, not a space.
|
|
236
|
+
const literal = target.replace(/\\/g, "\\\\");
|
|
237
|
+
enfLog.debug("cd_target_quoted", "target", literal);
|
|
238
|
+
return literal;
|
|
239
|
+
}
|
|
240
|
+
let end = i;
|
|
241
|
+
// An escaped character is part of the name: `weird\&name` is one directory.
|
|
242
|
+
while (end < args.length && !/[\s;&|)]/.test(args[end])) {
|
|
243
|
+
if (args[end] === "\\" && end + 1 < args.length) end++;
|
|
244
|
+
end++;
|
|
245
|
+
}
|
|
246
|
+
// Unquoted, a backslash before a newline is a continuation the shell removes
|
|
247
|
+
// entirely. Inside quotes it would be literal, which is why only this path
|
|
248
|
+
// joins and the quoted one above does not.
|
|
249
|
+
const target = args.slice(i, end).replace(/\\\n/g, "");
|
|
250
|
+
enfLog.debug("cd_target", "target", target);
|
|
251
|
+
return target || null;
|
|
252
|
+
}
|
|
253
|
+
// Bare `cd` goes home; `cd -` goes to the previous directory, which the
|
|
254
|
+
// caller resolves against what it tracked.
|
|
255
|
+
enfLog.debug("cd_target_default");
|
|
256
|
+
return "~";
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Apply one directory change to a running directory. */
|
|
260
|
+
function applyCd(dir: string, target: string, home = process.env.HOME ?? "~", fromShell = true): string {
|
|
261
|
+
// `cd -` with no previous directory tracked leaves the shell where it is. Only
|
|
262
|
+
// a shell's own `cd -` means that: a directory literally named `-`, reached as
|
|
263
|
+
// `git -C-`, is an ordinary relative directory.
|
|
264
|
+
if (target === "-" && fromShell) {
|
|
265
|
+
enfLog.debug("apply_cd_previous_untracked", "dir", dir);
|
|
266
|
+
return dir;
|
|
267
|
+
}
|
|
268
|
+
let next: string;
|
|
269
|
+
// The shell builds one word from backslash escapes: `weird\&name` is a single
|
|
270
|
+
// directory named `weird&name`, whatever else the name looks like.
|
|
271
|
+
const word = target.replace(/\\(.)/g, "$1");
|
|
272
|
+
if (word.startsWith("~/")) next = join(home, word.slice(2));
|
|
273
|
+
else if (word === "~") next = home;
|
|
274
|
+
else if (word.startsWith("/")) next = word;
|
|
275
|
+
else next = join(dir, word);
|
|
276
|
+
enfLog.debug("apply_cd", "from", dir, "target", target, "to", next);
|
|
277
|
+
return next;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Every `-C` directory override in a git segment, in order.
|
|
282
|
+
*
|
|
283
|
+
* Only git's *global* options count. `-C` after the subcommand is a different
|
|
284
|
+
* flag entirely — `git commit -C <sha>` reuses that commit's message — so
|
|
285
|
+
* reading it as a directory would send every git safety guard looking for a
|
|
286
|
+
* directory named after a commit, and skip the checks. The walk therefore stops
|
|
287
|
+
* at the first bare word, which is the subcommand.
|
|
288
|
+
*/
|
|
289
|
+
function gitCsIn(segment: string): string[] {
|
|
290
|
+
const tokens = tokenize(segment);
|
|
291
|
+
const start = tokens.findIndex((w) => w === "git");
|
|
292
|
+
if (start === -1) return [];
|
|
293
|
+
const values: string[] = [];
|
|
294
|
+
for (let i = start + 1; i < tokens.length; i++) {
|
|
295
|
+
const tok = tokens[i];
|
|
296
|
+
if (!tok.startsWith("-") || tok === "-") break;
|
|
297
|
+
if (tok === "-C" || tok.startsWith("-C") && tok.length > 2) {
|
|
298
|
+
const value = tok === "-C" ? tokens[i + 1] : tok.slice(2);
|
|
299
|
+
if (value) values.push(value);
|
|
300
|
+
if (tok === "-C") i++;
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
// Any other option may take a separate value; skip it if the next word is one.
|
|
304
|
+
if (!tok.includes("=") && tokens[i + 1] && !tokens[i + 1].startsWith("-")) i++;
|
|
305
|
+
}
|
|
306
|
+
if (values.length) enfLog.debug("git_C_options", "count", values.length, "last", values[values.length - 1]);
|
|
307
|
+
return values;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Words that run whatever follows them, so an assignment behind one still counts. */
|
|
311
|
+
const WRAPPER_WORDS = new Set(["sudo", "command", "exec", "time", "nohup", "nice", "stdbuf", "setsid", "timeout"]);
|
|
312
|
+
|
|
313
|
+
/** env options whose value is a separate word. */
|
|
314
|
+
const ENV_VALUE_OPTIONS = new Set([
|
|
315
|
+
"-u", "--unset", "-C", "--chdir", "-S", "--split-string", "-a", "--argv0",
|
|
316
|
+
"--block-signal", "--default-signal", "--ignore-signal", "--debug",
|
|
317
|
+
]);
|
|
318
|
+
|
|
319
|
+
const GIT_INDEX_VARS = new Set(["GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_COMMON_DIR"]);
|
|
320
|
+
const GIT_INDEX_OPTIONS = new Set(["--git-dir", "--work-tree", "--namespace"]);
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Anything that points git at a repository other than the working directory: the
|
|
324
|
+
* GIT_DIR/GIT_WORK_TREE/GIT_INDEX_FILE environment variables, or the equivalent
|
|
325
|
+
* long options. Their target is an index path, not a directory the walker can
|
|
326
|
+
* enumerate, so the caller refuses rather than checks the wrong places.
|
|
327
|
+
*
|
|
328
|
+
* Position matters. An environment variable only selects the index in *assignment
|
|
329
|
+
* position* — `GIT_DIR=/x git add a.txt`, optionally through `env` — so
|
|
330
|
+
* `git add GIT_INDEX_FILE=notes` stages a file with that name. The long options
|
|
331
|
+
* only count in git's global option region, before the subcommand, so
|
|
332
|
+
* `git add -- --work-tree=x` is a filename too.
|
|
333
|
+
*/
|
|
334
|
+
function gitIndexOverride(command: string): string | null {
|
|
335
|
+
const assignment = /^[A-Za-z_][A-Za-z0-9_]*=/;
|
|
336
|
+
for (const seg of unquotedSegments(command)) {
|
|
337
|
+
const tokens = tokenize(seg.text);
|
|
338
|
+
const nameOf = (tok: string) => tok.split("=")[0];
|
|
339
|
+
let i = 0;
|
|
340
|
+
// `export GIT_INDEX_FILE=/x` sets it for later commands in the same shell,
|
|
341
|
+
// so it points git at another index just as an inline assignment does. This
|
|
342
|
+
// segment does not run git itself, so it is checked before that requirement.
|
|
343
|
+
if (tokens[i] === "export") {
|
|
344
|
+
i++;
|
|
345
|
+
while (i < tokens.length) {
|
|
346
|
+
const name = nameOf(tokens[i]);
|
|
347
|
+
if (GIT_INDEX_VARS.has(name)) {
|
|
348
|
+
enfLog.warn("git_index_override", "source", name, "via", "export");
|
|
349
|
+
return name;
|
|
350
|
+
}
|
|
351
|
+
i++;
|
|
352
|
+
}
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
// `echo GIT_INDEX_FILE=/x` only prints the name. An override is something
|
|
356
|
+
// git is actually run with, so the segment has to run git for it to count.
|
|
357
|
+
if (!segmentRunsGit(seg.text)) continue;
|
|
358
|
+
// Leading `VAR=value` assignments, then the same again after `env`. A
|
|
359
|
+
// wrapper that runs the rest — `sudo`, `command`, `exec`, `time` — sits in
|
|
360
|
+
// front of both, and can be chained.
|
|
361
|
+
for (let round = 0; round < 4; round++) {
|
|
362
|
+
while (i < tokens.length && WRAPPER_WORDS.has(tokens[i])) i++;
|
|
363
|
+
while (i < tokens.length && assignment.test(tokens[i])) {
|
|
364
|
+
const name = nameOf(tokens[i]);
|
|
365
|
+
if (GIT_INDEX_VARS.has(name)) {
|
|
366
|
+
enfLog.warn("git_index_override", "source", name);
|
|
367
|
+
return name;
|
|
368
|
+
}
|
|
369
|
+
i++;
|
|
370
|
+
}
|
|
371
|
+
if (tokens[i] !== "env") break;
|
|
372
|
+
i++;
|
|
373
|
+
// env's own options: only these take a separate value, and that value must
|
|
374
|
+
// not be read as an assignment or as the command. `-i` takes none, so
|
|
375
|
+
// treating every option as value-taking would swallow the assignment after it.
|
|
376
|
+
while (i < tokens.length && tokens[i].startsWith("-") && tokens[i] !== "-") {
|
|
377
|
+
const opt = tokens[i].split("=")[0];
|
|
378
|
+
if (!tokens[i].includes("=") && ENV_VALUE_OPTIONS.has(opt)) i++;
|
|
379
|
+
i++;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
const g = tokens.indexOf("git");
|
|
383
|
+
if (g === -1) continue;
|
|
384
|
+
for (let k = g + 1; k < tokens.length; k++) {
|
|
385
|
+
const tok = tokens[k];
|
|
386
|
+
if (!tok.startsWith("-") || tok === "-") break;
|
|
387
|
+
const name = nameOf(tok);
|
|
388
|
+
if (GIT_INDEX_OPTIONS.has(name)) {
|
|
389
|
+
enfLog.warn("git_index_override", "source", name);
|
|
390
|
+
return name;
|
|
391
|
+
}
|
|
392
|
+
if (!tok.includes("=") && tokens[k + 1] && !tokens[k + 1].startsWith("-")) k++;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
return null;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Every directory the command could plausibly run git in.
|
|
400
|
+
*
|
|
401
|
+
* Candidates come from where git actually runs: the directory in effect at each
|
|
402
|
+
* git invocation, plus every `-C` resolved in order from that directory (git
|
|
403
|
+
* applies them one after another), plus any directory a `cd` reached only
|
|
404
|
+
* conditionally. Each subshell gets its own directory stack, because a `cd`
|
|
405
|
+
* inside one cannot move the outer shell — which is also why a `cd` in a
|
|
406
|
+
* subshell contributes a candidate without becoming the running directory.
|
|
407
|
+
*
|
|
408
|
+
* When an environment variable selects the index, the target is unknowable and
|
|
409
|
+
* `envOverride` says so, so the caller can refuse instead of guess.
|
|
410
|
+
*/
|
|
411
|
+
export function conflictCandidateDirs(
|
|
412
|
+
command: string,
|
|
413
|
+
sessionCwd: string,
|
|
414
|
+
): { all: string[]; envOverride: string | null; dynamicPath: string | null } {
|
|
415
|
+
const envOverride = gitIndexOverride(command);
|
|
416
|
+
// A path the shell expands at run time — `$DIR`, `$(pwd)`, a glob — cannot be
|
|
417
|
+
// checked here, so it is reported rather than resolved to a literal. A glob
|
|
418
|
+
// character the command escaped is part of the name, not a pattern.
|
|
419
|
+
const dynamic = (p: string) => /[$*?{]/.test(p.replace(/\\./g, ""));
|
|
420
|
+
let dynamicPath: string | null = null;
|
|
421
|
+
// Per paren depth: is the directory this shell is in one we could not resolve?
|
|
422
|
+
// A subshell's answer dies with the subshell, which is what makes a completed
|
|
423
|
+
// subshell stop mattering.
|
|
424
|
+
const dynamicAt: boolean[] = [false];
|
|
425
|
+
// Set when a command that stages ran in a directory we could not resolve. That
|
|
426
|
+
// is the case worth refusing: we do not know what it staged. A read-only
|
|
427
|
+
// command in the same directory is not, so it does not pin the refusal.
|
|
428
|
+
let unresolvedStaging = false;
|
|
429
|
+
const dirs = new Set<string>();
|
|
430
|
+
// running[d] is the directory of the shell at paren depth d.
|
|
431
|
+
const running: string[] = [sessionCwd];
|
|
432
|
+
// OLDPWD: where the shell was before the last change, which is what `cd -`
|
|
433
|
+
// returns to. Each depth has its own, and each starts where the shell did —
|
|
434
|
+
// the shell's own PWD, not just the directory we were invoked from.
|
|
435
|
+
const startPwd = process.env.PWD && process.env.PWD.startsWith("/") ? process.env.PWD : sessionCwd;
|
|
436
|
+
const previous: string[] = [startPwd];
|
|
437
|
+
// What `pushd` displaced, per depth, for `popd` to return to.
|
|
438
|
+
const pushed: string[][] = [[]];
|
|
439
|
+
// `HOME=/x git add a.txt` sends a bare `cd` and a `~` somewhere else, so the
|
|
440
|
+
// command's own assignment wins over this process's environment.
|
|
441
|
+
let home = process.env.HOME ?? "~";
|
|
442
|
+
// Walk the *executable* text: a `cd` inside a `bash -c` script, or inside a
|
|
443
|
+
// command substitution, moves the directory the staging really runs in.
|
|
444
|
+
const scan = executableText(command) || command;
|
|
445
|
+
|
|
446
|
+
for (const seg of unquotedSegments(scan)) {
|
|
447
|
+
// Leaving a subshell discards its directory, and entering a new one starts
|
|
448
|
+
// from the outer shell again — otherwise `(cd clean); (git add x)` would
|
|
449
|
+
// inherit `clean` for the second subshell.
|
|
450
|
+
if (running.length > seg.depth + 1) running.length = seg.depth + 1;
|
|
451
|
+
while (running.length <= seg.depth) running.push(running[running.length - 1]);
|
|
452
|
+
while (previous.length <= seg.depth) previous.push(previous[previous.length - 1]);
|
|
453
|
+
while (pushed.length <= seg.depth) pushed.push([]);
|
|
454
|
+
while (dynamicAt.length <= seg.depth) dynamicAt.push(dynamicAt[dynamicAt.length - 1]);
|
|
455
|
+
const assigned = /(?:^|[\s;&|(])HOME=(\S*)/.exec(seg.text);
|
|
456
|
+
if (assigned) home = assigned[1];
|
|
457
|
+
const target = cdTargetIn(seg.text);
|
|
458
|
+
if (target) {
|
|
459
|
+
if (seg.conditional || seg.backgrounded) {
|
|
460
|
+
// Behind a short-circuit the cd may never run, and a backgrounded cd
|
|
461
|
+
// runs in its own subshell — so in both cases the directory the shell is
|
|
462
|
+
// in *before* it is where a later command actually stages.
|
|
463
|
+
dirs.add(running[seg.depth]);
|
|
464
|
+
}
|
|
465
|
+
const from = running[seg.depth];
|
|
466
|
+
// `cd -` goes to OLDPWD as it stands *before* this change, and only then
|
|
467
|
+
// does the previous directory become where we are now.
|
|
468
|
+
let to: string;
|
|
469
|
+
if (target === "-") to = previous[seg.depth];
|
|
470
|
+
else if (target === "~popd") {
|
|
471
|
+
const stack = pushed[seg.depth];
|
|
472
|
+
to = stack.length ? stack.pop()! : from;
|
|
473
|
+
} else {
|
|
474
|
+
to = applyCd(from, target, home);
|
|
475
|
+
// A bare `pushd` pushes where we are and goes home, the same as a bare
|
|
476
|
+
// `cd`; a `cd` leaves the push stack alone.
|
|
477
|
+
if (/\bpushd\b/.test(seg.text) && !/^\s*(?:if\s+|then\s+|else\s+|elif\s+|do\s+|\(\s*|\{\s*)*pushd\s+\S/.test(seg.text)) {
|
|
478
|
+
pushed[seg.depth].push(from);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
if (seg.backgrounded) {
|
|
482
|
+
// The subshell moves; this shell does not.
|
|
483
|
+
dirs.add(to);
|
|
484
|
+
} else {
|
|
485
|
+
previous[seg.depth] = from;
|
|
486
|
+
running[seg.depth] = to;
|
|
487
|
+
}
|
|
488
|
+
// An expanded target leaves the directory unresolved. A relative move
|
|
489
|
+
// inherits that from where it started; an absolute literal path settles it.
|
|
490
|
+
dynamicAt[seg.depth] =
|
|
491
|
+
dynamic(target) || (!target.startsWith("/") && dynamicAt[seg.depth]);
|
|
492
|
+
if (!seg.conditional && !seg.backgrounded && target.startsWith("/") && !dynamic(target) && !unresolvedStaging) {
|
|
493
|
+
// Once a staging command has already run in an unresolved directory, the
|
|
494
|
+
// path stays reported: the refusal has to name the path we could not read.
|
|
495
|
+
dynamicPath = null;
|
|
496
|
+
}
|
|
497
|
+
if (dynamic(target) && !dynamicPath) dynamicPath = target;
|
|
498
|
+
}
|
|
499
|
+
if (segmentRunsGit(seg.text)) {
|
|
500
|
+
let base = running[seg.depth];
|
|
501
|
+
// `env -C dir git add x` moves git into dir without a shell cd.
|
|
502
|
+
const envChdir = /^\s*(?:VAR=\S*\s*)*env\s+(?:-\S+\s+)*?(?:-C|--chdir)(?:[=\s]+)(\S+)/.exec(seg.text);
|
|
503
|
+
if (envChdir) base = applyCd(base, envChdir[1], home);
|
|
504
|
+
dirs.add(base);
|
|
505
|
+
// Git applies each -C in turn, each relative to the previous one.
|
|
506
|
+
const overrides = gitCsIn(seg.text);
|
|
507
|
+
let cursor = base;
|
|
508
|
+
for (const c of overrides) {
|
|
509
|
+
// An expanded override is the directory this invocation actually runs
|
|
510
|
+
// in, so it is the one worth reporting over an earlier cd target.
|
|
511
|
+
if (dynamic(c)) dynamicPath = c;
|
|
512
|
+
// A directory literally named `-` is ordinary; only the shell's own
|
|
513
|
+
// `cd -` means the previous directory.
|
|
514
|
+
cursor = applyCd(cursor, c, home, false);
|
|
515
|
+
dirs.add(cursor);
|
|
516
|
+
}
|
|
517
|
+
// Where this invocation actually runs decides whether it is unresolved. A
|
|
518
|
+
// final absolute override names the worktree outright, so a directory the
|
|
519
|
+
// shell merely expanded on its way there is irrelevant to it.
|
|
520
|
+
const stages = isConflictResolutionCommand(seg.text);
|
|
521
|
+
const lastOverride = overrides[overrides.length - 1];
|
|
522
|
+
const named = lastOverride !== undefined && !dynamic(lastOverride) && lastOverride.startsWith("/");
|
|
523
|
+
if (stages && !named) {
|
|
524
|
+
if (dynamicAt[seg.depth] || overrides.some((c) => dynamic(c))) unresolvedStaging = true;
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
const all = [...dirs];
|
|
529
|
+
// Only a command that staged while the directory was unresolved is worth
|
|
530
|
+
// refusing. Otherwise the path was resolved, or only read from, by the end.
|
|
531
|
+
const unresolved = unresolvedStaging ? dynamicPath : null;
|
|
532
|
+
enfLog.debug("conflict_candidate_dirs", "count", all.length, "env_override", envOverride ?? "none", "dynamic_path", unresolved ?? "none", "unresolved_staging", unresolvedStaging);
|
|
533
|
+
return { all, envOverride, dynamicPath: unresolved };
|
|
534
|
+
}
|
|
535
|
+
|
|
62
536
|
/** Parse bash command for cd target to resolve the effective working directory (worktree support) */
|
|
63
537
|
export function resolveEffectiveCwd(command: string, sessionCwd: string): string {
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
//
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
538
|
+
// Quoting decides what is syntax, so segments are split outside quotes first.
|
|
539
|
+
// Then the LAST cd before the git invocation, applied in shell order so a
|
|
540
|
+
// chain of relative cds composes. Two cases make the obvious implementations
|
|
541
|
+
// wrong:
|
|
542
|
+
// (cd clean && cd conflicted && git add x) → the later cd wins
|
|
543
|
+
// cd /repo && git commit && cd /tmp → a trailing cd must not
|
|
544
|
+
// With no git invocation to anchor to, the first cd is used, which is the
|
|
545
|
+
// conservative answer for `cd a && cd b && pytest`.
|
|
546
|
+
//
|
|
547
|
+
// A command that can run git in more than one directory (a conditional branch,
|
|
548
|
+
// a completed subshell, several invocations) has no single answer here. That
|
|
549
|
+
// is why conflict enforcement asks conflictCandidateDirs instead of trusting
|
|
550
|
+
// this function's guess.
|
|
551
|
+
const segments = unquotedSegments(command);
|
|
552
|
+
// A segment only counts when it *runs* git: `cd ~/git/proj` merely contains
|
|
553
|
+
// the word, and anchoring on it would drop a real directory change.
|
|
554
|
+
const gitIndex = segments.findIndex((s) => segmentRunsGit(s.text));
|
|
555
|
+
// Only a cd that moves *this* shell counts. A cd inside a subshell, or one
|
|
556
|
+
// behind a short-circuit, runs somewhere else or may not run at all, so
|
|
557
|
+
// applying it sent the guards looking at a directory the shell never entered —
|
|
558
|
+
// `(cd /tmp) && git commit` read /tmp settings, where enforcement is off.
|
|
559
|
+
const targetDepth = gitIndex === -1 ? 0 : segments[gitIndex].depth;
|
|
560
|
+
const running: string[] = [sessionCwd];
|
|
561
|
+
const applied: string[] = [];
|
|
562
|
+
for (let i = 0; i < segments.length; i++) {
|
|
563
|
+
// A trailing cd after the git command did not happen yet.
|
|
564
|
+
if (i === gitIndex) break;
|
|
565
|
+
const seg = segments[i];
|
|
566
|
+
// A segment deeper than the git command runs in a subshell that has already
|
|
567
|
+
// closed by the time git runs, so it cannot have moved this shell. It is
|
|
568
|
+
// skipped rather than ending the walk: a subshell before the command is
|
|
569
|
+
// ordinary, and stopping there would drop every later directory change.
|
|
570
|
+
if (seg.depth > targetDepth) continue;
|
|
571
|
+
while (running.length <= seg.depth) running.push(running[running.length - 1]);
|
|
572
|
+
const target = cdTargetIn(seg.text);
|
|
573
|
+
if (!target || target === "~popd") continue;
|
|
574
|
+
// A change at a shallower depth still counts: `cd /protected && (git commit)`
|
|
575
|
+
// enters /protected first, and the subshell inherits it. Only deeper segments
|
|
576
|
+
// were ruled out above, because those run in a subshell that has closed by
|
|
577
|
+
// the time git runs. Note that a change behind a short-circuit still counts:
|
|
578
|
+
// `cd a && cd b` really does leave the shell in b, and `if cd x; then` leaves
|
|
579
|
+
// it in x too.
|
|
580
|
+
running[seg.depth] = applyCd(running[seg.depth], target);
|
|
581
|
+
applied.push(target);
|
|
582
|
+
// With no git invocation to anchor to, the first cd is the conservative
|
|
583
|
+
// answer for `cd a && cd b && pytest`.
|
|
584
|
+
if (gitIndex === -1) break;
|
|
80
585
|
}
|
|
81
|
-
|
|
586
|
+
// The git segment itself was never walked, so a subshell depth it introduced
|
|
587
|
+
// has no entry yet. It inherits the directory of the shell that spawned it.
|
|
588
|
+
while (running.length <= targetDepth) running.push(running[running.length - 1]);
|
|
589
|
+
let dir = running[targetDepth];
|
|
590
|
+
// Git applies each -C in turn, each relative to the previous one, so
|
|
591
|
+
// `git -C worktree -C nested` lands in worktree/nested.
|
|
592
|
+
const cTargets = gitIndex === -1 ? [] : gitCsIn(segments[gitIndex].text);
|
|
593
|
+
for (const cTarget of cTargets) dir = applyCd(dir, cTarget, process.env.HOME ?? "~", false);
|
|
594
|
+
enfLog.debug("effective_cwd", "dir", dir, "source", cTargets.length ? "git_C" : applied.length ? "cd" : "session", "cds", applied.length);
|
|
595
|
+
return dir;
|
|
82
596
|
}
|
|
83
597
|
|
|
84
598
|
/** Auto-fix direct python commands (prepend uv run), block pip commands */
|