Visitar URL original
Set GH_EXTENSION=1 when gh invokes an extension · cli/cli@093ca1c · GitHub
Skip to content

Commit 093ca1c

Browse files
Set GH_EXTENSION=1 when gh invokes an extension
An extension binary can be invoked either as `gh <ext>` or directly as `./gh-<ext>`, and until now the child process had no way to tell which. Extension authors want this so they can print usage strings that match how they were actually invoked. `Manager.Dispatch` is the single choke point for both `gh <ext>` and `gh extension exec <ext>`, so setting the variable there covers both. We use `Cmd.Environ()` rather than `os.Environ()` so that an environment explicitly set on the command is preserved, and we append last so gh's value wins over any inherited `GH_EXTENSION`. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: cd6441de-bed4-4adb-88c3-904b349ba16f
1 parent c6f4067 commit 093ca1c

4 files changed

Lines changed: 129 additions & 2 deletions

File tree

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# Verify that gh tells an extension when it is being run as an extension
2+
3+
# Skip if Bash is not available given script extension
4+
[!exec:bash] skip
5+
6+
# Setup environment variables used for testscript
7+
env EXT_NAME=printenv-${RANDOM_STRING}
8+
env EXT_DIR=gh-${EXT_NAME}
9+
10+
# Setup a local extension that reports the value of GH_EXTENSION
11+
mkdir $EXT_DIR
12+
mv print-env.sh $EXT_DIR/$EXT_DIR
13+
chmod 777 $EXT_DIR/$EXT_DIR
14+
15+
# Install the local extension, gh extension install only supports the working directory
16+
cd $EXT_DIR
17+
exec gh extension install .
18+
defer gh extension remove $EXT_NAME
19+
20+
# Verify GH_EXTENSION is set when the extension is run as gh <extension>
21+
exec gh $EXT_NAME
22+
stdout 'GH_EXTENSION=1'
23+
24+
# Verify GH_EXTENSION is set when the extension is run via gh extension exec
25+
exec gh extension exec $EXT_NAME
26+
stdout 'GH_EXTENSION=1'
27+
28+
# Verify GH_EXTENSION is absent when the extension is run standalone
29+
exec ./$EXT_DIR
30+
stdout 'GH_EXTENSION=0'
31+
32+
# Verify GH_EXTENSION is documented
33+
exec gh help environment
34+
stdout 'GH_EXTENSION`: set to `1` by gh when it invokes an extension'
35+
36+
-- print-env.sh --
37+
#!/usr/bin/env bash
38+
echo "GH_EXTENSION=${GH_EXTENSION:-0}"

‎pkg/cmd/extension/manager.go‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,12 @@ func (m *Manager) Dispatch(args []string, stdin io.Reader, stdout, stderr io.Wri
128128
forwardArgs = append([]string{"-c", `command "$@"`, "--", exe}, forwardArgs...)
129129
externalCmd = m.newCommand(shExe, forwardArgs...)
130130
}
131+
// Signal to the extension that it is being run by gh rather than standalone, so it can
132+
// adjust things like usage strings. Environ() preserves any Env already set on the
133+
// command and otherwise falls back to the parent environment; appending last means gh
134+
// wins over an inherited GH_EXTENSION.
135+
externalCmd.Env = append(externalCmd.Environ(), "GH_EXTENSION=1")
136+
131137
externalCmd.Stdin = stdin
132138
externalCmd.Stdout = stdout
133139
externalCmd.Stderr = stderr

‎pkg/cmd/extension/manager_test.go‎

Lines changed: 81 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,13 @@ func TestHelperProcess(t *testing.T) {
2929
return
3030
}
3131
if err := func(args []string) error {
32+
// Dispatch tests use this marker argument to inspect the environment gh handed to
33+
// the extension, rather than echoing the arguments back.
34+
if len(args) > 0 && args[len(args)-1] == "print-env" {
35+
fmt.Fprintf(os.Stdout, "GH_EXTENSION=%s\n", os.Getenv("GH_EXTENSION"))
36+
fmt.Fprintf(os.Stdout, "GH_HELPER_INHERITED=%s\n", os.Getenv("GH_HELPER_INHERITED"))
37+
return nil
38+
}
3239
fmt.Fprintf(os.Stdout, "%v\n", args)
3340
return nil
3441
}(os.Args[3:]); err != nil {
@@ -38,7 +45,7 @@ func TestHelperProcess(t *testing.T) {
3845
os.Exit(0)
3946
}
4047

41-
func newTestManager(dataDir, updateDir string, client *http.Client, gitClient gitClient, ios *iostreams.IOStreams) *Manager {
48+
func newTestManager(dataDir, updateDir string, client *http.Client, gitClient gitClient, ios *iostreams.IOStreams, extraEnv ...string) *Manager {
4249
return &Manager{
4350
dataDir: func() string { return dataDir },
4451
updateDir: func() string { return updateDir },
@@ -51,7 +58,7 @@ func newTestManager(dataDir, updateDir string, client *http.Client, gitClient gi
5158
cmd.Stdout = ios.Out
5259
cmd.Stderr = ios.ErrOut
5360
}
54-
cmd.Env = []string{"GH_WANT_HELPER_PROCESS=1"}
61+
cmd.Env = append([]string{"GH_WANT_HELPER_PROCESS=1"}, extraEnv...)
5562
return cmd
5663
},
5764
config: config.NewBlankConfig(),
@@ -191,6 +198,78 @@ func TestManager_Dispatch_binary(t *testing.T) {
191198
assert.Equal(t, "", stderr.String())
192199
}
193200

201+
// TestManager_Dispatch_ghExtensionEnv covers the environment gh hands to a dispatched
202+
// extension. The end to end behaviour lives in the acceptance tests; this exercises the
203+
// binary and Windows `sh` shim branches that a single OS acceptance run cannot reach.
204+
func TestManager_Dispatch_ghExtensionEnv(t *testing.T) {
205+
tests := []struct {
206+
name string
207+
extraEnv []string
208+
wantOut string
209+
}{
210+
{
211+
name: "sets GH_EXTENSION",
212+
wantOut: "GH_EXTENSION=1\nGH_HELPER_INHERITED=\n",
213+
},
214+
{
215+
name: "preserves the rest of the environment",
216+
extraEnv: []string{"GH_HELPER_INHERITED=yes"},
217+
wantOut: "GH_EXTENSION=1\nGH_HELPER_INHERITED=yes\n",
218+
},
219+
{
220+
name: "overrides an inherited GH_EXTENSION",
221+
extraEnv: []string{"GH_EXTENSION=0", "GH_HELPER_INHERITED=yes"},
222+
wantOut: "GH_EXTENSION=1\nGH_HELPER_INHERITED=yes\n",
223+
},
224+
}
225+
226+
for _, tt := range tests {
227+
t.Run("script extension: "+tt.name, func(t *testing.T) {
228+
dataDir := t.TempDir()
229+
updateDir := t.TempDir()
230+
extDir := filepath.Join(dataDir, "extensions", "gh-hello")
231+
require.NoError(t, stubExtension(filepath.Join(extDir, "gh-hello")))
232+
233+
gc, gcOne := &mockGitClient{}, &mockGitClient{}
234+
gc.On("ForRepo", extDir).Return(gcOne).Once()
235+
236+
m := newTestManager(dataDir, updateDir, nil, gc, nil, tt.extraEnv...)
237+
238+
stdout := &bytes.Buffer{}
239+
stderr := &bytes.Buffer{}
240+
found, err := m.Dispatch([]string{"hello", "print-env"}, nil, stdout, stderr)
241+
require.NoError(t, err)
242+
require.True(t, found)
243+
244+
assert.Equal(t, tt.wantOut, stdout.String())
245+
assert.Equal(t, "", stderr.String())
246+
})
247+
248+
t.Run("binary extension: "+tt.name, func(t *testing.T) {
249+
dataDir := t.TempDir()
250+
updateDir := t.TempDir()
251+
extDir := filepath.Join(dataDir, "extensions", "gh-hello")
252+
require.NoError(t, stubBinaryExtension(extDir, binManifest{
253+
Owner: "owner",
254+
Name: "gh-hello",
255+
Host: "github.com",
256+
Tag: "v1.0.0",
257+
}))
258+
259+
m := newTestManager(dataDir, updateDir, nil, nil, nil, tt.extraEnv...)
260+
261+
stdout := &bytes.Buffer{}
262+
stderr := &bytes.Buffer{}
263+
found, err := m.Dispatch([]string{"hello", "print-env"}, nil, stdout, stderr)
264+
require.NoError(t, err)
265+
require.True(t, found)
266+
267+
assert.Equal(t, tt.wantOut, stdout.String())
268+
assert.Equal(t, "", stderr.String())
269+
})
270+
}
271+
}
272+
194273
func TestManager_Remove(t *testing.T) {
195274
dataDir := t.TempDir()
196275
updateDir := t.TempDir()

‎pkg/cmd/root/help_topic.go‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,10 @@ var HelpTopics = []helpTopic{
9999
When an extension is executed, gh checks for new versions for the executed extension once every 24 hours.
100100
If a newer version was found, an upgrade notice is displayed on standard error.
101101
102+
%[1]sGH_EXTENSION%[1]s: set to %[1]s1%[1]s by gh when it invokes an extension, allowing an extension to
103+
tell whether it was run as %[1]sgh <extension>%[1]s or directly as a standalone program. Any value
104+
set in the environment is overwritten.
105+
102106
%[1]sGH_CONFIG_DIR%[1]s: the directory where gh will store configuration files. If not specified,
103107
the default value will be one of the following paths (in order of precedence):
104108
- %[1]s$XDG_CONFIG_HOME/gh%[1]s (if %[1]s$XDG_CONFIG_HOME%[1]s is set),

0 commit comments

Comments
 (0)