Skip to content

Commit 05b3926

Browse files
authored
refactor(global): clean up global edit command logic (#23)
* refactor(global): clean up global edit command logic - Align control flow with other commands (before/edit/after) - Ensure targetPath is always available for logging - Initialize variables early in beforeEdit - Add integration test for global edit * fix(global): execute EDITOR directly without shell
1 parent 270dd52 commit 05b3926

6 files changed

Lines changed: 223 additions & 0 deletions

File tree

cmd/global_edit.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/*
2+
Copyright © 2026 laggu
3+
*/
4+
package cmd
5+
6+
import (
7+
"github.com/laggu/git-volume/internal/gitvolume"
8+
"github.com/spf13/cobra"
9+
)
10+
11+
// editCmd represents the edit command
12+
var editCmd = &cobra.Command{
13+
Use: "edit <file>",
14+
Short: "Edit a file in the global git-volume directory",
15+
Long: `Opens a file from the global git-volume directory in your default editor.
16+
The editor is determined by the EDITOR environment variable, defaulting to 'vi'.
17+
18+
Examples:
19+
git volume global edit config.json
20+
git volume global edit secrets/api.key`,
21+
Args: cobra.ExactArgs(1),
22+
SilenceUsage: true,
23+
RunE: func(cmd *cobra.Command, args []string) error {
24+
gv, err := gitvolume.New(gitvolume.Options{Quiet: quiet})
25+
if err != nil {
26+
return err
27+
}
28+
return gv.GlobalEdit(args[0])
29+
},
30+
}
31+
32+
func init() {
33+
globalCmd.AddCommand(editCmd)
34+
}

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ require (
1010

1111
require (
1212
github.com/davecgh/go-spew v1.1.1 // indirect
13+
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect
1314
github.com/inconshreveable/mousetrap v1.1.0 // indirect
1415
github.com/kr/pretty v0.3.1 // indirect
1516
github.com/pmezard/go-difflib v1.0.0 // indirect

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6N
22
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
33
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
44
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
5+
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
6+
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
57
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
68
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
79
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=

internal/gitvolume/edit.go

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
package gitvolume
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"os/exec"
7+
"path/filepath"
8+
9+
"github.com/google/shlex"
10+
)
11+
12+
// GlobalEdit opens a file from the global git-volume directory in the default editor
13+
func (g *GitVolume) GlobalEdit(file string) error {
14+
targetPath, editor, err := g.beforeEdit(file)
15+
if err == nil {
16+
err = g.edit(targetPath, editor)
17+
}
18+
19+
return g.afterEdit(targetPath, err)
20+
}
21+
22+
func (g *GitVolume) beforeEdit(file string) (targetPath, editor string, err error) {
23+
globalDir := g.ctx.GlobalDir
24+
targetPath = file // Initialize with input file for logging in case of early error
25+
editor = os.Getenv("EDITOR")
26+
if editor == "" {
27+
editor = "vi" // Default to vi if EDITOR is not set
28+
}
29+
30+
// Check if global directory exists
31+
if _, statErr := os.Stat(globalDir); os.IsNotExist(statErr) {
32+
err = fmt.Errorf("global directory does not exist: %s", globalDir)
33+
return
34+
}
35+
36+
// Resolve target path
37+
targetPath = filepath.Join(globalDir, file)
38+
39+
// Security: check for path traversal
40+
if err = verifyPathWithinBase(targetPath, globalDir); err != nil {
41+
err = fmt.Errorf("invalid file path: %w", err)
42+
return
43+
}
44+
45+
// Check if file exists
46+
if _, statErr := os.Stat(targetPath); os.IsNotExist(statErr) {
47+
err = fmt.Errorf("file does not exist: %s", targetPath)
48+
return
49+
}
50+
51+
return
52+
}
53+
54+
func (g *GitVolume) edit(targetPath, editor string) error {
55+
parts, err := shlex.Split(editor)
56+
if err != nil {
57+
return fmt.Errorf("failed to parse EDITOR value %q: %w", editor, err)
58+
}
59+
if len(parts) == 0 {
60+
return fmt.Errorf("invalid EDITOR value: empty command")
61+
}
62+
63+
cmd := exec.Command(parts[0], append(parts[1:], targetPath)...)
64+
cmd.Stdin = os.Stdin
65+
cmd.Stdout = os.Stdout
66+
cmd.Stderr = os.Stderr
67+
68+
if err := cmd.Run(); err != nil {
69+
return fmt.Errorf("failed to run editor %s: %w", editor, err)
70+
}
71+
72+
return nil
73+
}
74+
75+
func (g *GitVolume) afterEdit(targetPath string, err error) error {
76+
if err != nil {
77+
if !g.quiet {
78+
fmt.Printf("❌ Failed to edit %s: %v\n", targetPath, err)
79+
}
80+
return err
81+
}
82+
83+
if !g.quiet {
84+
fmt.Printf("✓ Edited %s\n", targetPath)
85+
}
86+
return nil
87+
}

internal/gitvolume/edit_test.go

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
package gitvolume
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"testing"
7+
8+
"github.com/stretchr/testify/assert"
9+
"github.com/stretchr/testify/require"
10+
)
11+
12+
func TestGitVolume_GlobalEdit(t *testing.T) {
13+
// Setup temporary directory for global storage
14+
tmpDir := t.TempDir()
15+
globalDir := filepath.Join(tmpDir, "global")
16+
err := os.MkdirAll(globalDir, 0755)
17+
require.NoError(t, err)
18+
19+
// Create a dummy GitVolume instance
20+
gv := &GitVolume{
21+
ctx: &Context{
22+
GlobalDir: globalDir,
23+
},
24+
quiet: true,
25+
}
26+
27+
t.Run("Edit existing file", func(t *testing.T) {
28+
// Create a file to edit
29+
targetFile := filepath.Join(globalDir, "config.txt")
30+
err := os.WriteFile(targetFile, []byte("initial content"), 0644)
31+
require.NoError(t, err)
32+
33+
// Mock EDITOR to a script that modifies the file
34+
// We use printf to avoid portability issues with echo -n
35+
originalEditor := os.Getenv("EDITOR")
36+
defer os.Setenv("EDITOR", originalEditor)
37+
os.Setenv("EDITOR", "sh -c 'printf \" - edited\" >> \"$1\"' --")
38+
39+
err = gv.GlobalEdit("config.txt")
40+
assert.NoError(t, err)
41+
42+
// Verify file content was updated
43+
content, err := os.ReadFile(targetFile)
44+
require.NoError(t, err)
45+
assert.Equal(t, "initial content - edited", string(content))
46+
})
47+
48+
t.Run("Edit non-existent file", func(t *testing.T) {
49+
err := gv.GlobalEdit("missing.txt")
50+
assert.Error(t, err)
51+
assert.Contains(t, err.Error(), "file does not exist")
52+
})
53+
54+
t.Run("Security: Block path traversal", func(t *testing.T) {
55+
err := gv.GlobalEdit("../outside.txt")
56+
assert.Error(t, err)
57+
assert.Contains(t, err.Error(), "invalid file path")
58+
})
59+
60+
t.Run("Global directory does not exist", func(t *testing.T) {
61+
// Create a GitVolume with non-existent global dir
62+
gvMissing := &GitVolume{
63+
ctx: &Context{
64+
GlobalDir: filepath.Join(tmpDir, "missing-global"),
65+
},
66+
quiet: true,
67+
}
68+
err := gvMissing.GlobalEdit("anything.txt")
69+
assert.Error(t, err)
70+
assert.Contains(t, err.Error(), "global directory does not exist")
71+
})
72+
}

test/integration.sh

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,33 @@ else
233233
fail "global list missing tree connectors"
234234
fi
235235

236+
# -----------------------------------------------------------------------------
237+
# Test: global edit
238+
# -----------------------------------------------------------------------------
239+
log "TEST" "Testing 'global edit' command..."
240+
241+
# Setup global file if not exists
242+
if [[ ! -f "$TEST_DIR/.git-volume/global_source.txt" ]]; then
243+
echo "GLOBAL_SECRET" > "$TEST_DIR/.git-volume/global_source.txt"
244+
fi
245+
246+
# Edit file using mocked EDITOR
247+
# We use a simple script that appends text
248+
export EDITOR="sh -c 'printf \" - EDITED\" >> \"\$1\"' --"
249+
"$GV_BIN" global edit global_source.txt
250+
251+
# Verify content
252+
CONTENT=$(cat "$TEST_DIR/.git-volume/global_source.txt")
253+
# Tricky: echo adds newline, printf might not depending on implementation
254+
# Let's just check if it contains the edited string
255+
if grep -q "EDITED" "$TEST_DIR/.git-volume/global_source.txt"; then
256+
pass "global edit modified file correctly"
257+
else
258+
fail "global edit failed to modify file"
259+
echo "Content: $CONTENT"
260+
fi
261+
unset EDITOR
262+
236263
# -----------------------------------------------------------------------------
237264
# Test: global remove
238265
# -----------------------------------------------------------------------------

0 commit comments

Comments
 (0)