@amsterdamdatalabs/adl-agent-control-linux-x64 0.1.33 → 0.1.34
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/bin/adl +2 -2
- package/package.json +1 -1
- package/packages/agent-hooks/src/rules/impl/git-branch.ts +63 -0
- package/packages/agent-hooks/src/rules/impl/git-helpers.ts +91 -3
- package/packages/agent-hooks/src/rules/impl/git-history.ts +4 -13
- package/packages/agent-hooks/src/rules/impl/git-target.ts +423 -0
- package/packages/agent-hooks/src/rules/ports.ts +36 -0
- package/packages/agent-hooks/src/rules/registry.ts +3 -2
- package/packages/agent-settings/assets/GLOBAL-AGENTS.md +5 -0
package/bin/adl
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
[diffend] Oversized file quarantined before diffing.
|
|
2
2
|
name: package/bin/adl
|
|
3
|
-
size:
|
|
4
|
-
sha256:
|
|
3
|
+
size: 82945504 bytes
|
|
4
|
+
sha256: b6594d01b2ebea8949f80faa8da029598f8be7513fb1e3c7b63ddc01df4c8f02
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@amsterdamdatalabs/adl-agent-control-linux-x64",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.34",
|
|
4
4
|
"description": "adl CLI binary for linux/x64 (agent identity + Azure DevOps front door — see docs/architecture/cli-design.md)",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"os": [
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// PBI 661 (Tarun, 2026-10-09): agents never change branches; both denies say "ask the user for git branch operations". See docs/architecture/agent-git-branch-rules.md
|
|
2
|
+
|
|
3
|
+
import type { Invocation } from '../shell/invocation.ts'
|
|
4
|
+
import type { Finding, MessageContext, RuleContext, RuleSpec } from '../spec.ts'
|
|
5
|
+
import { noParams } from '../spec.ts'
|
|
6
|
+
import { branchMutates, isHelpOnlyInvocation, isSequencerRecoveryOnly, switchCheckoutCreatesBranch } from './git-helpers.ts'
|
|
7
|
+
import { LANDING_SUBCOMMANDS, resolveGitTarget } from './git-target.ts'
|
|
8
|
+
import { gitAgentTail } from './messages.ts'
|
|
9
|
+
|
|
10
|
+
const ASK_USER = 'ask the user for git branch operations'
|
|
11
|
+
|
|
12
|
+
const UNRESOLVED =
|
|
13
|
+
'The commit target (which repository and branch this would land on) could not be determined statically from this command line. ' +
|
|
14
|
+
'Run `git commit` as its own command, or with `git -C <path>`, so the target is explicit. '
|
|
15
|
+
|
|
16
|
+
function askUserMessage(ctx: MessageContext, unresolved = false) {
|
|
17
|
+
return { user: ASK_USER, agent: `DENIED ${ASK_USER}. ${unresolved ? UNRESOLVED : ''}${gitAgentTail(ctx.snippet)}` }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const gitBranchMutation: RuleSpec = {
|
|
21
|
+
id: 'git.branch-mutation',
|
|
22
|
+
category: 'git',
|
|
23
|
+
severity: 'deny',
|
|
24
|
+
enabledByDefault: true,
|
|
25
|
+
summary: 'Deny every mutating git branch form and branch creation via git switch -c / git checkout -b.',
|
|
26
|
+
params: noParams,
|
|
27
|
+
match(inv: Invocation): Finding | null {
|
|
28
|
+
if (inv.kind !== 'git') return null
|
|
29
|
+
const hit =
|
|
30
|
+
inv.subcommand === 'branch' ? branchMutates(inv.rest) : inv.subcommand === 'switch' || inv.subcommand === 'checkout' ? switchCheckoutCreatesBranch(inv.subcommand, inv.rest) : false
|
|
31
|
+
return hit ? { ruleId: 'git.branch-mutation', severity: 'deny', detail: {} } : null
|
|
32
|
+
},
|
|
33
|
+
message: (_finding: Finding, ctx: MessageContext) => askUserMessage(ctx),
|
|
34
|
+
corpus: {
|
|
35
|
+
deny: ['git branch foo', 'git branch -D old-feature', 'git branch -aD', 'git branch --delete --force old-feature', 'git branch -m old new', 'git switch -c new-branch', 'git checkout -b new-branch'],
|
|
36
|
+
allow: ['git branch', 'git branch -a', 'git branch -vv', 'git branch --show-current', "git branch --list 'feat/*'", 'git switch main', 'git checkout feature-x'],
|
|
37
|
+
},
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const gitCommitProtectedBranch: RuleSpec = {
|
|
41
|
+
id: 'git.commit-protected-branch',
|
|
42
|
+
category: 'git',
|
|
43
|
+
severity: 'deny',
|
|
44
|
+
enabledByDefault: true,
|
|
45
|
+
summary: 'Deny git commit (and merge, cherry-pick, revert, am) while the target repo is on a protected branch, or when the target or its branch cannot be resolved.',
|
|
46
|
+
params: noParams,
|
|
47
|
+
match(inv: Invocation, ctx: RuleContext<Record<string, never>>): Finding | null {
|
|
48
|
+
if (inv.kind !== 'git' || !LANDING_SUBCOMMANDS.has(inv.subcommand)) return null
|
|
49
|
+
if (isHelpOnlyInvocation(inv.rest)) return null // prints text, commits nothing
|
|
50
|
+
if (inv.subcommand !== 'commit' && isSequencerRecoveryOnly(inv.rest)) return null // --abort / --quit only end an operation
|
|
51
|
+
const target = resolveGitTarget(inv, ctx.command)
|
|
52
|
+
// null target or branch = unresolved: fail closed (docs/architecture/agent-git-branch-rules.md)
|
|
53
|
+
const branch = target === null ? null : ctx.index.currentBranch(target)
|
|
54
|
+
if (branch !== null && !ctx.agentConfig.protectedBranches.includes(branch)) return null
|
|
55
|
+
return { ruleId: 'git.commit-protected-branch', severity: 'deny', detail: { unresolved: branch === null ? 'yes' : 'no' } }
|
|
56
|
+
},
|
|
57
|
+
message: (finding: Finding, ctx: MessageContext) => askUserMessage(ctx, finding.detail['unresolved'] === 'yes'),
|
|
58
|
+
// Fixture branch is never protected, so deny entries are the fail-closed ones (docs/architecture/agent-git-branch-rules.md)
|
|
59
|
+
corpus: {
|
|
60
|
+
deny: ['cd "$REPO" && git commit -m x', 'git -C "$REPO" commit -m x', 'GIT_DIR="$D" git merge origin/integration'],
|
|
61
|
+
allow: ["git commit -m 'feat(x): y'", 'git status'],
|
|
62
|
+
},
|
|
63
|
+
}
|
|
@@ -61,9 +61,97 @@ export function commitAmends(rest: readonly Token[]): boolean {
|
|
|
61
61
|
return hasFlag(rest, '--amend')
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
64
|
+
/**
|
|
65
|
+
* Every long option `git branch -h` lists on git 2.56 (positive forms), plus the
|
|
66
|
+
* legacy `--set-upstream`. Used ONLY to resolve unique prefixes the way git's
|
|
67
|
+
* parse-options does; whether an option is allowed is `BRANCH_READ_ONLY_OPTIONS`.
|
|
68
|
+
*/
|
|
69
|
+
const BRANCH_POSITIVE_OPTIONS: readonly string[] = [
|
|
70
|
+
'verbose', 'quiet', 'track', 'set-upstream-to', 'set-upstream', 'unset-upstream', 'color', 'remotes', 'contains', 'no-contains', 'abbrev', 'all',
|
|
71
|
+
'delete', 'move', 'omit-empty', 'copy', 'list', 'show-current', 'create-reflog', 'edit-description', 'delete-merged', 'dry-run', 'force', 'merged',
|
|
72
|
+
'no-merged', 'forked', 'column', 'sort', 'points-at', 'ignore-case', 'recurse-submodules', 'format', 'help',
|
|
73
|
+
]
|
|
74
|
+
const BRANCH_ALL_OPTIONS: readonly string[] = [...new Set([...BRANCH_POSITIVE_OPTIONS, ...BRANCH_POSITIVE_OPTIONS.filter((name) => !name.startsWith('no-') && name !== 'help' && name !== 'remotes' && name !== 'all').map((name) => `no-${name}`)])]
|
|
75
|
+
/** PBI 661 allowlist: the only `git branch` options that read and never write. Anything else (known or not) is a deny. */
|
|
76
|
+
const BRANCH_READ_ONLY_OPTIONS: ReadonlySet<string> = new Set([
|
|
77
|
+
'verbose', 'quiet', 'color', 'remotes', 'all', 'list', 'show-current', 'omit-empty', 'ignore-case', 'dry-run', 'abbrev', 'column', 'help',
|
|
78
|
+
'contains', 'no-contains', 'merged', 'no-merged', 'forked', 'sort', 'points-at', 'format',
|
|
79
|
+
'no-verbose', 'no-quiet', 'no-color', 'no-abbrev', 'no-column', 'no-sort', 'no-points-at', 'no-format', 'no-omit-empty', 'no-ignore-case', 'no-dry-run',
|
|
80
|
+
])
|
|
81
|
+
/** Read-only options whose separate next token is their value. */
|
|
82
|
+
const BRANCH_VALUE_OPTIONS: ReadonlySet<string> = new Set(['contains', 'no-contains', 'merged', 'no-merged', 'forked', 'sort', 'points-at', 'format'])
|
|
83
|
+
/** Read-only options that put `git branch` in list mode, where positionals are patterns, not new branch names. */
|
|
84
|
+
const BRANCH_LIST_OPTIONS: ReadonlySet<string> = new Set(['list', 'show-current', 'contains', 'no-contains', 'merged', 'no-merged', 'points-at', 'forked'])
|
|
85
|
+
/** `-v -q -r -a -l -i`, alone or clustered; every other short letter writes or is unknown. */
|
|
86
|
+
const BRANCH_READ_ONLY_LETTERS: readonly string[] = ['v', 'q', 'r', 'a', 'l', 'i']
|
|
87
|
+
|
|
88
|
+
/** git's parse-options rule: an exact name wins, else the unique name that starts with `name`; `null` when unknown or ambiguous. */
|
|
89
|
+
function resolveLongOption(name: string, table: readonly string[]): string | null {
|
|
90
|
+
if (table.includes(name)) return name
|
|
91
|
+
const candidates = table.filter((candidate) => candidate.startsWith(name))
|
|
92
|
+
return candidates.length === 1 ? (candidates[0] as string) : null
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* PBI 661: does this `git branch` argv change branches, or might it? Fail
|
|
97
|
+
* closed: only an argv made entirely of known read-only options (long options
|
|
98
|
+
* resolved by unique prefix as git does) is false. A bare positional with no
|
|
99
|
+
* list-mode option is a branch name to CREATE, so it is true.
|
|
100
|
+
*/
|
|
101
|
+
export function branchMutates(rest: readonly Token[]): boolean {
|
|
102
|
+
if (isHelpOnlyInvocation(rest)) return false
|
|
103
|
+
let listMode = false
|
|
104
|
+
let positionals = 0
|
|
105
|
+
for (let index = 0; index < rest.length; index++) {
|
|
106
|
+
const value = tokenValue(rest[index] as Token)
|
|
107
|
+
if (value === '--') {
|
|
108
|
+
positionals += rest.length - index - 1
|
|
109
|
+
break
|
|
110
|
+
}
|
|
111
|
+
if (value.startsWith('--')) {
|
|
112
|
+
const name = resolveLongOption(value.slice(2).split('=')[0] as string, BRANCH_ALL_OPTIONS)
|
|
113
|
+
if (name === null || !BRANCH_READ_ONLY_OPTIONS.has(name)) return true
|
|
114
|
+
if (BRANCH_LIST_OPTIONS.has(name)) listMode = true
|
|
115
|
+
if (BRANCH_VALUE_OPTIONS.has(name) && !value.includes('=')) index++
|
|
116
|
+
} else if (value.startsWith('-') && value.length > 1) {
|
|
117
|
+
const letters = [...value.slice(1)]
|
|
118
|
+
if (letters.some((letter) => !BRANCH_READ_ONLY_LETTERS.includes(letter))) return true
|
|
119
|
+
if (letters.includes('l')) listMode = true
|
|
120
|
+
} else {
|
|
121
|
+
positionals++
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return positionals > 0 && !listMode
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Long options that create a branch, per subcommand; `NON_CREATING` are the long options whose exact name must win over a longer creating one. */
|
|
128
|
+
const CREATING_LONG_OPTIONS: Readonly<Record<string, readonly string[]>> = {
|
|
129
|
+
switch: ['create', 'force-create', 'orphan', 'track'],
|
|
130
|
+
checkout: ['orphan', 'track'],
|
|
131
|
+
}
|
|
132
|
+
const NON_CREATING_LONG_OPTIONS: readonly string[] = ['force', 'detach', 'discard-changes', 'merge', 'conflict', 'quiet', 'progress', 'no-progress', 'guess', 'no-guess', 'no-track', 'ignore-other-worktrees', 'recurse-submodules', 'no-recurse-submodules', 'overlay', 'no-overlay', 'ours', 'theirs', 'patch']
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* PBI 661: `git switch -c/-C/-t/--create/--force-create/--orphan/--track` and
|
|
136
|
+
* `git checkout -b/-B/-t/--orphan/--track` create a branch, including any
|
|
137
|
+
* unique (or ambiguous-with-a-creating) long-option prefix (`--cr`, `--force-c`,
|
|
138
|
+
* `--orp`, `--tr`). Plain `git switch|checkout <existing>` is not matched.
|
|
139
|
+
*/
|
|
140
|
+
export function switchCheckoutCreatesBranch(subcommand: string, rest: readonly Token[]): boolean {
|
|
141
|
+
const longs = CREATING_LONG_OPTIONS[subcommand] ?? []
|
|
142
|
+
const letters = subcommand === 'switch' ? ['c', 'C', 't'] : ['b', 'B', 't']
|
|
143
|
+
for (const token of rest) {
|
|
144
|
+
const value = tokenValue(token)
|
|
145
|
+
if (value === '--') return false
|
|
146
|
+
if (value.startsWith('--')) {
|
|
147
|
+
const name = value.slice(2).split('=')[0] as string
|
|
148
|
+
if (longs.includes(name)) return true
|
|
149
|
+
if (name.length > 0 && !NON_CREATING_LONG_OPTIONS.includes(name) && longs.some((long) => long.startsWith(name))) return true
|
|
150
|
+
} else if (value.startsWith('-') && value.length > 1 && [...value.slice(1)].some((letter) => letters.includes(letter))) {
|
|
151
|
+
return true
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return false
|
|
67
155
|
}
|
|
68
156
|
|
|
69
157
|
export function tagDeletes(rest: readonly Token[]): boolean {
|
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `
|
|
2
|
+
* `tag -d`, `switch -f`, `gc --prune[=<date>]`.
|
|
3
3
|
* (`update-ref` in every form lives in git-plumbing.ts.)
|
|
4
4
|
*
|
|
5
5
|
* Ported from `blockedGitHistoryReason` in blocked-reason.mjs:78-86. None of
|
|
6
6
|
* these bypass on a bare `--help`/`--version` the way the "core" rules do —
|
|
7
7
|
* the original's `blockedGitPolicyReason` is called unconditionally, with no
|
|
8
|
-
* `rmIsHelpOnly` gate — so neither does this. All
|
|
8
|
+
* `rmIsHelpOnly` gate — so neither does this. All three fall through
|
|
9
9
|
* `classifyReason`'s default arm.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import type { Invocation } from '../shell/invocation.ts'
|
|
13
13
|
import type { Finding, MessageContext, RuleSpec } from '../spec.ts'
|
|
14
14
|
import { noParams } from '../spec.ts'
|
|
15
|
-
import {
|
|
15
|
+
import { gcPrunesExplicitly, switchIsForced, tagDeletes } from './git-helpers.ts'
|
|
16
16
|
import { defaultGitMessage, detailString } from './messages.ts'
|
|
17
17
|
|
|
18
18
|
function git(inv: Invocation): (Invocation & { kind: 'git' }) | null {
|
|
@@ -40,15 +40,6 @@ function make(id: string, subcommand: string, reasonFor: () => string, hits: (g:
|
|
|
40
40
|
}
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
export const gitBranchForceDelete = make(
|
|
44
|
-
'git.branch-force-delete',
|
|
45
|
-
'branch',
|
|
46
|
-
() => 'git branch -D',
|
|
47
|
-
(g) => branchForceDeletes(g.rest),
|
|
48
|
-
['git branch -D old-feature', 'git branch -aD', 'git branch --delete --force old-feature'],
|
|
49
|
-
['git branch -d merged-feature', 'git branch feature-x', 'git branch -a'],
|
|
50
|
-
)
|
|
51
|
-
|
|
52
43
|
export const gitTagDelete = make(
|
|
53
44
|
'git.tag-delete',
|
|
54
45
|
'tag',
|
|
@@ -64,7 +55,7 @@ export const gitSwitchForce = make(
|
|
|
64
55
|
() => 'git switch -f',
|
|
65
56
|
(g) => switchIsForced(g.rest),
|
|
66
57
|
['git switch -f main', 'git switch --force main'],
|
|
67
|
-
['git switch main', 'git switch
|
|
58
|
+
['git switch main', 'git switch feat/existing'],
|
|
68
59
|
)
|
|
69
60
|
|
|
70
61
|
export const gitGcPrune = make(
|
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
// Best-effort static resolver of the repository a commit-like git command lands on. See docs/architecture/agent-git-branch-rules.md#resolver
|
|
2
|
+
|
|
3
|
+
import { statSync } from 'node:fs'
|
|
4
|
+
import { homedir } from 'node:os'
|
|
5
|
+
import type { BranchTarget } from '../ports.ts'
|
|
6
|
+
import type { Invocation } from '../shell/invocation.ts'
|
|
7
|
+
import { withoutRedirections } from '../shell/git-internals.ts'
|
|
8
|
+
import { splitShell } from '../shell/split.ts'
|
|
9
|
+
import { findMatchingBacktick, findMatchingParen } from '../shell/substitution.ts'
|
|
10
|
+
import { type Token, basename, tokenValue, tokenize } from '../shell/token.ts'
|
|
11
|
+
|
|
12
|
+
// Subcommands that add history to the current branch (pull, rebase, reset, amend are excluded: see docs/architecture/agent-git-branch-rules.md)
|
|
13
|
+
export const LANDING_SUBCOMMANDS: ReadonlySet<string> = new Set(['commit', 'merge', 'cherry-pick', 'revert', 'am'])
|
|
14
|
+
|
|
15
|
+
const ASSIGNMENT_RE = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/
|
|
16
|
+
const SHELLS: ReadonlySet<string> = new Set(['sh', 'bash', 'zsh', 'dash', 'ksh'])
|
|
17
|
+
/** Commands that re-run another command: a shell `-c` script behind one is followed. */
|
|
18
|
+
const RE_EXECUTORS: ReadonlySet<string> = new Set(['xargs', 'parallel', 'env', 'nice', 'nohup', 'timeout', 'sudo', 'doas'])
|
|
19
|
+
|
|
20
|
+
// Bash builtins and reserved words: `compgen -b` + `compgen -k` on bash 5.3 (docs/architecture/agent-git-branch-rules.md#closed-builtin-sets-and-their-provenance)
|
|
21
|
+
const BASH_BUILTINS_AND_KEYWORDS: readonly string[] = (
|
|
22
|
+
'. : [ alias bg bind break builtin caller cd command compgen complete compopt continue declare dirs disown echo enable eval exec exit export false fc fg getopts hash help history jobs kill let local logout mapfile popd printf pushd pwd read readarray readonly return set shift shopt source suspend test times trap true type typeset ulimit umask unalias unset wait if then else elif fi case esac for select while until do done in function time { } ! [[ ]] coproc').split(' ')
|
|
23
|
+
// zsh builtins and reserved words: `zsh -c 'print ${(k)builtins} ${(k)reswords}'` on zsh 5.9 (same doc section)
|
|
24
|
+
const ZSH_BUILTINS_AND_RESERVED_WORDS: readonly string[] = (
|
|
25
|
+
'unset rehash popd ulimit local jobs disable [ compfiles printf autoload noglob pushln zle readonly exit false times sched setopt getln builtin let bg which unhash pwd zparseopts logout disown type source eval comptags compdescribe compctl r zmodload zregexparse history return exec compadd emulate chdir ttyctl test comparguments pushd functions float zstyle print declare comptry alias shift - . bindkey typeset true hash compset cd compvalues getopts compgroups export enable limit echotc echo wait dirs unsetopt read : integer bye echoti compquote unfunction fc vared unalias kill compcall where fg zformat suspend unlimit break set continue command zcompile whence umask trap log private if else end do then { select coproc } ! case [[ repeat done for while time esac until fi nocorrect foreach elif function'
|
|
26
|
+
).split(' ')
|
|
27
|
+
/** The closed set of shell builtins and reserved words (bash 5.3 + zsh 5.9): a head outside it is an external command or a function. */
|
|
28
|
+
const SHELL_BUILTINS_AND_KEYWORDS: ReadonlySet<string> = new Set([...BASH_BUILTINS_AND_KEYWORDS, ...ZSH_BUILTINS_AND_RESERVED_WORDS])
|
|
29
|
+
/** Builtins that only print or test: neutral (`printf -v` / `print -v` assign a variable and are not). */
|
|
30
|
+
const READ_ONLY_BUILTINS: ReadonlySet<string> = new Set(['echo', 'printf', 'print', 'which', 'whence', 'where', 'true', 'false', 'test', '[', '[[', 'pwd', 'type', 'hash', 'help', ':', 'times', 'dirs', 'jobs'])
|
|
31
|
+
/** Variables whose assignment (or export, or unset) changes what a later cd, `~` or shell startup does: unknown. */
|
|
32
|
+
const SENSITIVE_VARIABLES: ReadonlySet<string> = new Set(['BASH_ENV', 'ENV', 'HOME', 'CDPATH', 'ZDOTDIR', 'PATH'])
|
|
33
|
+
/** Variables that, as a prefix on a launched shell, change what that shell runs or means. */
|
|
34
|
+
const SHELL_STARTUP_VARIABLES: ReadonlySet<string> = new Set(['BASH_ENV', 'ENV', 'ZDOTDIR', 'HOME', 'CDPATH'])
|
|
35
|
+
/** git subcommands that move HEAD (or the worktree it is read from): after one, the branch a later landing command hits is unknown. */
|
|
36
|
+
const HEAD_MOVERS: ReadonlySet<string> = new Set(['switch', 'checkout', 'worktree', 'stash', 'bisect', 'rebase', 'reset'])
|
|
37
|
+
const GIT_VALUE_GLOBALS: ReadonlySet<string> = new Set(['-C', '-c', '--git-dir', '--work-tree', '--namespace', '--super-prefix', '--config-env'])
|
|
38
|
+
|
|
39
|
+
/** Does this piece run `git <HEAD_MOVER> ...` (behind any wrapper)? */
|
|
40
|
+
function movesHead(rest: readonly Token[]): boolean {
|
|
41
|
+
const gitAt = rest.findIndex((token) => ['git', 'git.exe'].includes(basename(token)))
|
|
42
|
+
if (gitAt < 0) return false
|
|
43
|
+
for (let i = gitAt + 1; i < rest.length; i++) {
|
|
44
|
+
const value = tokenValue(rest[i] as Token)
|
|
45
|
+
if (value.startsWith('-')) {
|
|
46
|
+
if (GIT_VALUE_GLOBALS.has(value)) i++
|
|
47
|
+
continue
|
|
48
|
+
}
|
|
49
|
+
const subcommand = value.toLowerCase()
|
|
50
|
+
if (!HEAD_MOVERS.has(subcommand)) return false
|
|
51
|
+
// Read-only forms of stash/worktree do not move HEAD: `stash list|show`, `worktree list`.
|
|
52
|
+
const action = rest.slice(i + 1).map((token) => tokenValue(token)).find((arg) => !arg.startsWith('-'))
|
|
53
|
+
if (subcommand === 'stash' && (action === 'list' || action === 'show')) return false
|
|
54
|
+
if (subcommand === 'worktree' && action === 'list') return false
|
|
55
|
+
return true
|
|
56
|
+
}
|
|
57
|
+
return false
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function namesDirectory(word: string): boolean {
|
|
61
|
+
try {
|
|
62
|
+
return statSync(word).isDirectory()
|
|
63
|
+
} catch {
|
|
64
|
+
return false
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface Mutable {
|
|
69
|
+
dirs: string[]
|
|
70
|
+
gitDir?: string
|
|
71
|
+
workTree?: string
|
|
72
|
+
/** `HOME=` was assigned for this command: `~` no longer means the home directory we know. */
|
|
73
|
+
homeUnknown?: boolean
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** `~`, `~/x`, `$HOME`, `${HOME}` expand to the home directory; any other `$` or backtick is not static (`null`). */
|
|
77
|
+
function expandPath(value: string, target: Mutable): string | null {
|
|
78
|
+
const home = homedir()
|
|
79
|
+
const tilde = value === '~' || value.startsWith('~/')
|
|
80
|
+
if (tilde) return target.homeUnknown === true ? null : home + value.slice(1)
|
|
81
|
+
for (const prefix of ['${HOME}', '$HOME']) {
|
|
82
|
+
if (value === prefix || value.startsWith(`${prefix}/`)) return target.homeUnknown === true ? null : home + value.slice(prefix.length)
|
|
83
|
+
}
|
|
84
|
+
return /[$`]/.test(value) || value === '' ? null : value
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Apply `NAME=value` env assignments we care about; false when one is not static. */
|
|
88
|
+
function applyEnv(target: Mutable, assignments: readonly string[]): boolean {
|
|
89
|
+
for (const assignment of assignments) {
|
|
90
|
+
const match = ASSIGNMENT_RE.exec(assignment)
|
|
91
|
+
if (!match) continue
|
|
92
|
+
const name = match[1] as string
|
|
93
|
+
if (name !== 'GIT_DIR' && name !== 'GIT_WORK_TREE') continue
|
|
94
|
+
const value = expandPath(match[2] as string, target)
|
|
95
|
+
if (value === null) return false
|
|
96
|
+
if (name === 'GIT_DIR') target.gitDir = value
|
|
97
|
+
else target.workTree = value
|
|
98
|
+
}
|
|
99
|
+
return true
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function leadingAssignments(tokens: readonly Token[]): { assignments: string[]; rest: Token[] } {
|
|
103
|
+
let index = 0
|
|
104
|
+
const assignments: string[] = []
|
|
105
|
+
while (index < tokens.length && ASSIGNMENT_RE.test(tokenValue(tokens[index] as Token))) assignments.push(tokenValue(tokens[index++] as Token))
|
|
106
|
+
return { assignments, rest: tokens.slice(index) as Token[] }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** `--git-dir`, `--work-tree`, `-C` among git's globals; false when one is not static. */
|
|
110
|
+
function applyGlobals(target: Mutable, globals: readonly Token[]): boolean {
|
|
111
|
+
const values = globals.map((token) => tokenValue(token))
|
|
112
|
+
for (let index = 0; index < values.length; index++) {
|
|
113
|
+
const value = values[index] as string
|
|
114
|
+
const take = (long: string): string | null => {
|
|
115
|
+
if (value === long) return values[++index] ?? ''
|
|
116
|
+
if (value.startsWith(`${long}=`)) return value.slice(long.length + 1)
|
|
117
|
+
return null
|
|
118
|
+
}
|
|
119
|
+
const gitDir = take('--git-dir')
|
|
120
|
+
if (gitDir !== null) {
|
|
121
|
+
const expanded = expandPath(gitDir, target)
|
|
122
|
+
if (expanded === null) return false
|
|
123
|
+
target.gitDir = expanded
|
|
124
|
+
continue
|
|
125
|
+
}
|
|
126
|
+
const workTree = take('--work-tree')
|
|
127
|
+
if (workTree !== null) {
|
|
128
|
+
const expanded = expandPath(workTree, target)
|
|
129
|
+
if (expanded === null) return false
|
|
130
|
+
target.workTree = expanded
|
|
131
|
+
continue
|
|
132
|
+
}
|
|
133
|
+
if (value === '-C' || (value.startsWith('-C') && value.length > 2)) {
|
|
134
|
+
const dir = expandPath(value === '-C' ? (values[++index] ?? '') : value.slice(2), target)
|
|
135
|
+
if (dir === null) return false
|
|
136
|
+
target.dirs.push(dir)
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return true
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Does this piece run our git invocation (possibly behind `env`/`command`/`builtin`/another wrapper)? */
|
|
143
|
+
function runsInvocation(piece: readonly Token[], inv: Invocation & { kind: 'git' }): boolean {
|
|
144
|
+
const tokens = withoutRedirections(piece) // the classifier's `rest` has none either (`git commit -F - <<EOF`)
|
|
145
|
+
const values = tokens.map((token) => tokenValue(token))
|
|
146
|
+
const wanted = inv.rest.map((token) => tokenValue(token))
|
|
147
|
+
const tail = wanted.length === 0 ? values : values.slice(-wanted.length)
|
|
148
|
+
const gitAt = tokens.findIndex((token) => ['git', 'git.exe'].includes(basename(token)))
|
|
149
|
+
return gitAt >= 0 && values.indexOf(inv.subcommand, gitAt) > gitAt && tail.length === wanted.length && tail.every((value, index) => value === wanted[index])
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Assignments reaching git from the piece: leading `NAME=v`, and `env NAME=v ...`. `null` when `env` carries options we do not model. */
|
|
153
|
+
function pieceAssignments(tokens: readonly Token[]): string[] | null {
|
|
154
|
+
const { assignments, rest } = leadingAssignments(tokens)
|
|
155
|
+
let index = 0
|
|
156
|
+
while (index < rest.length && !['git', 'git.exe'].includes(basename(rest[index] as Token))) {
|
|
157
|
+
const name = basename(rest[index] as Token)
|
|
158
|
+
if (name === 'env') {
|
|
159
|
+
index++
|
|
160
|
+
while (index < rest.length && ASSIGNMENT_RE.test(tokenValue(rest[index] as Token))) assignments.push(tokenValue(rest[index++] as Token))
|
|
161
|
+
if (index < rest.length && tokenValue(rest[index] as Token).startsWith('-') && !['git', 'git.exe'].includes(basename(rest[index] as Token))) return null
|
|
162
|
+
} else if (name === 'command' || name === 'builtin') {
|
|
163
|
+
index++
|
|
164
|
+
while (index < rest.length && ['-p', '--'].includes(tokenValue(rest[index] as Token))) index++
|
|
165
|
+
} else {
|
|
166
|
+
index++ // another wrapper (nohup, time, ...): no environment of ours
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return assignments
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
interface Parsed {
|
|
173
|
+
readonly tokens: readonly (readonly Token[] | null)[]
|
|
174
|
+
/** Text between consecutive pieces (operators, group characters, comments); `null` when the pieces could not be located in the source. */
|
|
175
|
+
readonly gaps: readonly string[] | null
|
|
176
|
+
/** Names of functions defined anywhere in the command (`f() {`, `function f`): a piece headed by one is unknown. */
|
|
177
|
+
readonly functions: ReadonlySet<string>
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Heredoc delimiters opened by `<<` / `<<-` at the top level of one piece, or `'unparsable'`. Quotes, `\x` and `$(...)` are skipped. */
|
|
181
|
+
function heredocDelimiters(piece: string): string[] | 'unparsable' {
|
|
182
|
+
const delimiters: string[] = []
|
|
183
|
+
for (let i = 0; i < piece.length; i++) {
|
|
184
|
+
const char = piece[i] as string
|
|
185
|
+
if (char === '\\') i++
|
|
186
|
+
else if (char === "'") {
|
|
187
|
+
const close = piece.indexOf("'", i + 1)
|
|
188
|
+
if (close < 0) return 'unparsable'
|
|
189
|
+
i = close
|
|
190
|
+
} else if (char === '"') {
|
|
191
|
+
let j = i + 1
|
|
192
|
+
while (j < piece.length && piece[j] !== '"') j += piece[j] === '\\' ? 2 : 1
|
|
193
|
+
if (j >= piece.length) return 'unparsable'
|
|
194
|
+
i = j
|
|
195
|
+
} else if (char === '`') {
|
|
196
|
+
const close = findMatchingBacktick(piece, i)
|
|
197
|
+
if (close < 0) return 'unparsable'
|
|
198
|
+
i = close
|
|
199
|
+
} else if (char === '$' && piece[i + 1] === '(') {
|
|
200
|
+
const close = findMatchingParen(piece, i + 1)
|
|
201
|
+
if (close < 0) return 'unparsable'
|
|
202
|
+
i = close
|
|
203
|
+
} else if (char === '<' && piece[i + 1] === '<' && piece[i + 2] !== '<' && piece[i - 1] !== '<') {
|
|
204
|
+
i += 2
|
|
205
|
+
if (piece[i] === '-') i++
|
|
206
|
+
while (piece[i] === ' ' || piece[i] === '\t') i++
|
|
207
|
+
let word = ''
|
|
208
|
+
let sawWord = false
|
|
209
|
+
for (; i < piece.length && !/[\s;|&<>()]/.test(piece[i] as string); i++) {
|
|
210
|
+
sawWord = true
|
|
211
|
+
const c = piece[i] as string
|
|
212
|
+
if (c === '\\') word += piece[++i] ?? ''
|
|
213
|
+
else if (c === "'" || c === '"') {
|
|
214
|
+
const close = piece.indexOf(c, i + 1)
|
|
215
|
+
if (close < 0) return 'unparsable'
|
|
216
|
+
word += piece.slice(i + 1, close)
|
|
217
|
+
i = close
|
|
218
|
+
} else word += c
|
|
219
|
+
}
|
|
220
|
+
if (!sawWord) return 'unparsable'
|
|
221
|
+
delimiters.push(word)
|
|
222
|
+
i--
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return delimiters
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** `null`: unparseable (parse.uncertain refuses it). `'closed'`: a heredoc we cannot account for -- fail closed. */
|
|
229
|
+
function parse(command: string): Parsed | null | 'closed' {
|
|
230
|
+
const split = splitShell(command)
|
|
231
|
+
if (split.invalid) return null
|
|
232
|
+
const tokens: (readonly Token[] | null)[] = split.pieces.map((piece) => {
|
|
233
|
+
const tokenized = tokenize(piece)
|
|
234
|
+
return tokenized.invalid ? null : tokenized.tokens
|
|
235
|
+
})
|
|
236
|
+
const functions = new Set<string>()
|
|
237
|
+
for (const match of command.matchAll(/([A-Za-z_][\w.:-]*)\s*\(\s*\)/g)) functions.add(match[1] as string)
|
|
238
|
+
for (const match of command.matchAll(/\bfunction\s+([A-Za-z_][\w.:-]*)/g)) functions.add(match[1] as string)
|
|
239
|
+
const gaps: string[] = []
|
|
240
|
+
let cursor = 0
|
|
241
|
+
for (const piece of split.pieces) {
|
|
242
|
+
const at = command.indexOf(piece, cursor)
|
|
243
|
+
if (at < 0) return { tokens, gaps: null, functions }
|
|
244
|
+
gaps.push(command.slice(cursor, at))
|
|
245
|
+
cursor = at + piece.length
|
|
246
|
+
}
|
|
247
|
+
// Heredoc body lines are not commands: drop them (docs/architecture/agent-git-branch-rules.md#resolver)
|
|
248
|
+
for (let i = 0; i < split.pieces.length; i++) {
|
|
249
|
+
const delimiters = heredocDelimiters(split.pieces[i] as string)
|
|
250
|
+
if (delimiters === 'unparsable') return 'closed'
|
|
251
|
+
if (delimiters.length === 0) continue
|
|
252
|
+
let body = i + 1
|
|
253
|
+
while (body < split.pieces.length && !(gaps[body] as string).includes('\n')) body++ // rest of the marker's own line
|
|
254
|
+
for (const delimiter of delimiters) {
|
|
255
|
+
let closed = false
|
|
256
|
+
for (; body < split.pieces.length && !closed; body++) {
|
|
257
|
+
closed = (split.pieces[body] as string).trim() === delimiter
|
|
258
|
+
tokens[body] = null
|
|
259
|
+
gaps[body] = '\n'
|
|
260
|
+
}
|
|
261
|
+
if (!closed) return 'closed'
|
|
262
|
+
}
|
|
263
|
+
i = body - 1
|
|
264
|
+
}
|
|
265
|
+
return { tokens, gaps, functions }
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** The gap as an operator when it is exactly `&&`, `;` or a newline; `null` for anything else (`||`, `|`, `&`, `(`, `{`, ...). */
|
|
269
|
+
function plainGap(gap: string): '&&' | ';' | null {
|
|
270
|
+
const normalized = gap.replace(/#[^\n]*/g, '').replace(/\s*\n\s*/g, ';').replace(/\s+/g, '').replace(/;+/g, ';')
|
|
271
|
+
return normalized === '&&' ? '&&' : normalized === ';' || normalized === '' ? ';' : null
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
type StatePiece =
|
|
275
|
+
| { kind: 'cd'; dir: string }
|
|
276
|
+
| { kind: 'export'; assignments: string[] }
|
|
277
|
+
| { kind: 'unset'; names: string[] }
|
|
278
|
+
| { kind: 'bad' }
|
|
279
|
+
| { kind: 'neutral' }
|
|
280
|
+
|
|
281
|
+
const NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/
|
|
282
|
+
const SET_CLUSTER_RE = /^[-+][eux]*$/
|
|
283
|
+
const SET_O_VALUES: readonly string[] = ['pipefail', 'errexit', 'nounset']
|
|
284
|
+
|
|
285
|
+
/** `set` with only -e/-u/-x/+e/+u/+x (clusters allowed, `-euo pipefail`) and `-o|+o pipefail|errexit|nounset`. */
|
|
286
|
+
function harmlessSet(args: readonly string[]): boolean {
|
|
287
|
+
if (args.length === 0) return false
|
|
288
|
+
for (let i = 0; i < args.length; i++) {
|
|
289
|
+
const arg = args[i] as string
|
|
290
|
+
const cluster = /^([-+][eux]*)o$/.exec(arg)
|
|
291
|
+
if (cluster !== null || arg === '-o' || arg === '+o') {
|
|
292
|
+
if (!SET_O_VALUES.includes(args[++i] ?? '')) return false
|
|
293
|
+
} else if (!SET_CLUSTER_RE.test(arg) || arg.length < 2) {
|
|
294
|
+
return false
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
return true
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function classify(piece: readonly Token[] | null, functions: ReadonlySet<string>): StatePiece {
|
|
301
|
+
if (piece === null) return { kind: 'neutral' }
|
|
302
|
+
const { assignments, rest } = leadingAssignments(withoutRedirections(piece))
|
|
303
|
+
const names = assignments.map((assignment) => assignment.split('=')[0] as string)
|
|
304
|
+
if (names.some((name) => name.startsWith('GIT_'))) return rest.length === 0 ? { kind: 'bad' } : classifyHead(rest, assignments, functions)
|
|
305
|
+
if (rest.length === 0) return names.some((name) => SENSITIVE_VARIABLES.has(name)) ? { kind: 'bad' } : { kind: 'neutral' }
|
|
306
|
+
return classifyHead(rest, assignments, functions)
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function classifyHead(rest: readonly Token[], assignments: readonly string[], functions: ReadonlySet<string>): StatePiece {
|
|
310
|
+
const head = tokenValue(rest[0] as Token)
|
|
311
|
+
const args = rest.slice(1).map((token) => tokenValue(token))
|
|
312
|
+
if (/[$`]/.test(head) || functions.has(head)) return { kind: 'bad' }
|
|
313
|
+
if (!SHELL_BUILTINS_AND_KEYWORDS.has(head)) {
|
|
314
|
+
if (movesHead(rest)) return { kind: 'bad' }
|
|
315
|
+
// zsh autocd: a path (or a bare word naming a directory) used as a command changes directory.
|
|
316
|
+
if (head.includes('/') && !['git', 'git.exe'].includes(basename(rest[0] as Token))) return { kind: 'bad' }
|
|
317
|
+
if (head.startsWith('~') || head === '..' || (!head.includes('/') && namesDirectory(head))) return { kind: 'bad' }
|
|
318
|
+
// An external command cannot change this shell, but a shell it launches runs its startup files first.
|
|
319
|
+
const startup = assignments.some((assignment) => SHELL_STARTUP_VARIABLES.has(assignment.split('=')[0] as string))
|
|
320
|
+
return startup && (SHELLS.has(basename(rest[0] as Token)) || RE_EXECUTORS.has(basename(rest[0] as Token))) ? { kind: 'bad' } : { kind: 'neutral' }
|
|
321
|
+
}
|
|
322
|
+
if (READ_ONLY_BUILTINS.has(head)) return (head === 'printf' || head === 'print') && args.includes('-v') ? { kind: 'bad' } : { kind: 'neutral' }
|
|
323
|
+
if (head === 'command' && (args[0] === '-v' || args[0] === '-V')) return { kind: 'neutral' }
|
|
324
|
+
if (head === 'set') return harmlessSet(args) ? { kind: 'neutral' } : { kind: 'bad' }
|
|
325
|
+
if (head === 'cd' || head === 'pushd') return args.length !== 1 || args[0] === '' || (args[0] as string).startsWith('-') ? { kind: 'bad' } : { kind: 'cd', dir: args[0] as string }
|
|
326
|
+
if (head === 'export') {
|
|
327
|
+
if (args.length > 0 && args.every((arg) => /^GIT_(DIR|WORK_TREE)=/.test(arg))) return { kind: 'export', assignments: args }
|
|
328
|
+
return args.every((arg) => NAME_RE.test(arg.split('=')[0] as string) && !arg.startsWith('GIT_') && !SENSITIVE_VARIABLES.has(arg.split('=')[0] as string)) ? { kind: 'neutral' } : { kind: 'bad' }
|
|
329
|
+
}
|
|
330
|
+
if (head === 'unset') return args.every((arg) => NAME_RE.test(arg) && !SENSITIVE_VARIABLES.has(arg)) ? { kind: 'unset', names: args } : { kind: 'bad' }
|
|
331
|
+
return { kind: 'bad' }
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Credit or reject the pieces before `until`; fail closed on anything unknown. // See docs/architecture/agent-git-branch-rules.md#resolver
|
|
335
|
+
function applyState(parsed: Parsed, until: number, target: Mutable): boolean {
|
|
336
|
+
for (let i = 0; i < until; i++) {
|
|
337
|
+
const state = classify(parsed.tokens[i] ?? null, parsed.functions)
|
|
338
|
+
if (state.kind === 'neutral') continue
|
|
339
|
+
if (state.kind === 'bad') return false
|
|
340
|
+
if (state.kind === 'unset') {
|
|
341
|
+
if (state.names.includes('GIT_DIR')) delete target.gitDir
|
|
342
|
+
if (state.names.includes('GIT_WORK_TREE')) delete target.workTree
|
|
343
|
+
continue
|
|
344
|
+
}
|
|
345
|
+
if (parsed.gaps === null) return false
|
|
346
|
+
const before = plainGap(parsed.gaps[i] as string)
|
|
347
|
+
if (before === null) return false
|
|
348
|
+
if (before === '&&' && (i === 0 || !['cd', 'export'].includes(classify(parsed.tokens[i - 1] ?? null, parsed.functions).kind))) return false
|
|
349
|
+
for (let j = i + 1; j <= Math.min(until, parsed.gaps.length - 1); j++) if (plainGap(parsed.gaps[j] as string) === null) return false
|
|
350
|
+
if (state.kind === 'cd') {
|
|
351
|
+
const dir = expandPath(state.dir, target)
|
|
352
|
+
if (dir === null) return false
|
|
353
|
+
target.dirs.push(dir)
|
|
354
|
+
} else if (!applyEnv(target, state.assignments)) {
|
|
355
|
+
return false
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
return true
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// The `-c` script of a (re-executor-wrapped) shell piece, or 'unsafe' when a startup-file variable is set. // See docs/architecture/agent-git-branch-rules.md#resolver
|
|
362
|
+
function shellScript(tokens: readonly Token[]): string | null | 'unsafe' {
|
|
363
|
+
const { rest } = leadingAssignments(tokens)
|
|
364
|
+
if (rest[0] === undefined) return null
|
|
365
|
+
let shellAt = 0
|
|
366
|
+
if (!SHELLS.has(basename(rest[0]))) {
|
|
367
|
+
if (!RE_EXECUTORS.has(basename(rest[0]))) return null
|
|
368
|
+
shellAt = rest.findIndex((token, index) => index > 0 && SHELLS.has(basename(token)))
|
|
369
|
+
if (shellAt < 0) return null
|
|
370
|
+
}
|
|
371
|
+
for (let index = shellAt + 1; index < rest.length - 1; index++) {
|
|
372
|
+
if (/^-[a-zA-Z]*c$/.test(tokenValue(rest[index] as Token))) {
|
|
373
|
+
const assignments = pieceAssignments(tokens)
|
|
374
|
+
return assignments === null || assignments.some((assignment) => SHELL_STARTUP_VARIABLES.has(assignment.split('=')[0] as string)) ? 'unsafe' : tokenValue(rest[index + 1] as Token)
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
return null
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** `undefined`: the invocation is not in `command`. `null`: it is, but its target is not static. */
|
|
381
|
+
function walk(command: string, inv: Invocation & { kind: 'git' }, inherited: Mutable): BranchTarget | null | undefined {
|
|
382
|
+
const parsed = parse(command)
|
|
383
|
+
if (parsed === null) return undefined // unparseable: parse.uncertain's job to refuse
|
|
384
|
+
if (parsed === 'closed') return null
|
|
385
|
+
for (let i = 0; i < parsed.tokens.length; i++) {
|
|
386
|
+
const tokens = parsed.tokens[i]
|
|
387
|
+
if (tokens === null || tokens === undefined) continue
|
|
388
|
+
const target: Mutable = { ...inherited, dirs: [...inherited.dirs] }
|
|
389
|
+
if (runsInvocation(tokens, inv)) {
|
|
390
|
+
const assignments = pieceAssignments(tokens)
|
|
391
|
+
if (assignments === null || !applyState(parsed, i, target)) return null
|
|
392
|
+
if (assignments.some((assignment) => assignment.startsWith('HOME='))) target.homeUnknown = true
|
|
393
|
+
if (!applyEnv(target, assignments) || !applyGlobals(target, inv.globals)) return null
|
|
394
|
+
return target
|
|
395
|
+
}
|
|
396
|
+
const script = shellScript(tokens)
|
|
397
|
+
if (script === null) continue
|
|
398
|
+
if (script === 'unsafe') {
|
|
399
|
+
// The startup file may do anything, but only matters when our git is in there.
|
|
400
|
+
const unsafeInner = tokens.some((token) => tokenValue(token).includes(inv.subcommand))
|
|
401
|
+
if (unsafeInner) return null
|
|
402
|
+
continue
|
|
403
|
+
}
|
|
404
|
+
const probe: Mutable = { ...inherited, dirs: [...inherited.dirs] }
|
|
405
|
+
const inner = walk(script, inv, probe)
|
|
406
|
+
if (inner === undefined) continue
|
|
407
|
+
if (inner === null || !applyState(parsed, i, target)) return null
|
|
408
|
+
const nested = walk(script, inv, target)
|
|
409
|
+
return nested ?? null
|
|
410
|
+
}
|
|
411
|
+
return undefined
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** The statically resolved target of `inv` inside `command`, or `null` when it cannot be resolved. */
|
|
415
|
+
export function resolveGitTarget(inv: Invocation & { kind: 'git' }, command: string | undefined): BranchTarget | null {
|
|
416
|
+
const found = command === undefined ? undefined : walk(command, inv, { dirs: [] })
|
|
417
|
+
if (found !== undefined) return found
|
|
418
|
+
// Not found as a piece (xargs, find -exec, ...): its own globals, after any unconditional cd/export before it.
|
|
419
|
+
const target: Mutable = { dirs: [] }
|
|
420
|
+
const parsed = command === undefined ? null : parse(command)
|
|
421
|
+
if (parsed === 'closed' || (parsed !== null && !applyState(parsed, parsed.tokens.length, target))) return null
|
|
422
|
+
return applyGlobals(target, inv.globals) ? target : null
|
|
423
|
+
}
|
|
@@ -45,6 +45,14 @@ export interface AgentPort {
|
|
|
45
45
|
* `git.protected-tree-commit` can fail closed on "could not read the index"
|
|
46
46
|
* instead of silently treating an unreadable index as a clean one.
|
|
47
47
|
*/
|
|
48
|
+
/** Where a git invocation points, statically resolved from its command line (see `impl/git-target.ts`). */
|
|
49
|
+
export interface BranchTarget {
|
|
50
|
+
/** `cd <dir>` and `git -C <dir>` values, in the order git applies them. */
|
|
51
|
+
readonly dirs: readonly string[]
|
|
52
|
+
readonly gitDir?: string
|
|
53
|
+
readonly workTree?: string
|
|
54
|
+
}
|
|
55
|
+
|
|
48
56
|
export interface RepoIndexPort {
|
|
49
57
|
stagedPaths(): readonly string[] | null
|
|
50
58
|
trackedModifiedPaths(): readonly string[] | null
|
|
@@ -64,6 +72,14 @@ export interface RepoIndexPort {
|
|
|
64
72
|
* absent) the same as `null` (present, resolved to nothing).
|
|
65
73
|
*/
|
|
66
74
|
configuredAlias?(verb: string): string | null
|
|
75
|
+
/**
|
|
76
|
+
* The checked-out branch of the repo a git invocation targets: the hook's
|
|
77
|
+
* root with `target.dirs` applied as `-C <dir>` values in order (relative
|
|
78
|
+
* ones composed), then `--git-dir` / `--work-tree` when given. `''` for a detached HEAD, `null` when it cannot be read (not a
|
|
79
|
+
* repository, git missing, timeout) so `git.commit-protected-branch` can
|
|
80
|
+
* fail closed instead of treating an unreadable HEAD as a safe one.
|
|
81
|
+
*/
|
|
82
|
+
currentBranch(target: BranchTarget): string | null
|
|
67
83
|
}
|
|
68
84
|
|
|
69
85
|
export const liveEnv: EnvPort = {
|
|
@@ -159,6 +175,26 @@ export function liveRepoIndex(root: string): RepoIndexPort {
|
|
|
159
175
|
}
|
|
160
176
|
},
|
|
161
177
|
resolveRemoteUrl: rewriteUrl,
|
|
178
|
+
currentBranch: (target) => {
|
|
179
|
+
try {
|
|
180
|
+
const out = execFileSync('git', [
|
|
181
|
+
'-C',
|
|
182
|
+
root,
|
|
183
|
+
...target.dirs.flatMap((dir) => ['-C', dir]),
|
|
184
|
+
...(target.gitDir === undefined ? [] : ['--git-dir', target.gitDir]),
|
|
185
|
+
...(target.workTree === undefined ? [] : ['--work-tree', target.workTree]),
|
|
186
|
+
'branch',
|
|
187
|
+
'--show-current',
|
|
188
|
+
], {
|
|
189
|
+
encoding: 'utf8',
|
|
190
|
+
timeout: 10_000,
|
|
191
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
192
|
+
})
|
|
193
|
+
return out.replace(/\n+$/, '')
|
|
194
|
+
} catch {
|
|
195
|
+
return null
|
|
196
|
+
}
|
|
197
|
+
},
|
|
162
198
|
configuredAlias: (verb) => {
|
|
163
199
|
try {
|
|
164
200
|
const out = execFileSync('git', ['-C', root, 'config', '--get', `alias.${verb}`], {
|
|
@@ -29,11 +29,11 @@ import { gitStash, gitClean, gitRebase, gitFilterBranch, gitRestore, gitReset, g
|
|
|
29
29
|
import { gitNoVerify, gitAmend } from './impl/git-verify.ts'
|
|
30
30
|
import { gitProtectedTreeCommit } from './impl/git-protected-tree.ts'
|
|
31
31
|
import {
|
|
32
|
-
gitBranchForceDelete,
|
|
33
32
|
gitTagDelete,
|
|
34
33
|
gitSwitchForce,
|
|
35
34
|
gitGcPrune,
|
|
36
35
|
} from './impl/git-history.ts'
|
|
36
|
+
import { gitBranchMutation, gitCommitProtectedBranch } from './impl/git-branch.ts'
|
|
37
37
|
import {
|
|
38
38
|
gitUpdateRef,
|
|
39
39
|
gitSymbolicRefWrite,
|
|
@@ -95,7 +95,8 @@ export const GIT_RULES: readonly AnyRuleSpec[] = [
|
|
|
95
95
|
gitNoVerify,
|
|
96
96
|
gitAmend,
|
|
97
97
|
gitProtectedTreeCommit,
|
|
98
|
-
|
|
98
|
+
gitBranchMutation,
|
|
99
|
+
gitCommitProtectedBranch,
|
|
99
100
|
gitTagDelete,
|
|
100
101
|
gitSwitchForce,
|
|
101
102
|
gitWorktree,
|
|
@@ -6,6 +6,11 @@ repo's own `AGENTS.md`.
|
|
|
6
6
|
## Communication
|
|
7
7
|
- Address me as **Tarun**.
|
|
8
8
|
- Short, scannable **bullet points** — not long prose. Lead with the answer; keep reasoning tight.
|
|
9
|
+
- **Every reply has three parts, in this order:**
|
|
10
|
+
- a **TL;DR** of a few lines; information stays brief, and detail is given only when asked;
|
|
11
|
+
- an explicit **Recommendation**: what you would do and why, in one line;
|
|
12
|
+
- **one ask**: the single question or decision you need next.
|
|
13
|
+
- **Keep a numbered list of open points** and show it when it changes. I answer them one at a time, so never bundle several questions into one reply.
|
|
9
14
|
- If a task is ambiguous, ask exactly one clarifying question.
|
|
10
15
|
|
|
11
16
|
## Truthfulness (non-negotiable)
|