detached 9bf0749

feat(app): add isolated development environments (#253)

* feat(app): add isolated development environments

* fix(dev): make environment tests portable
commit
9bf07496d182d7d5ebd8528ffe10a80147c794db
author
bnema · · 2026-09-04 09:10 UTC
committer
GitHub · · 2026-09-04 09:10 UTC
parent
bddd2db
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
7 files · +426 -0
M .gitignore +1 -0
@@ -1,4 +1,5 @@
11 /vev
2+.dev/
23 *.test
34 .superpowers/
45 docs/superpowers/
@@ -1,4 +1,5 @@
1 /vev1 /vev
2+.dev/
2 *.test3 *.test
3 .superpowers/4 .superpowers/
4 docs/superpowers/5 docs/superpowers/
M README.md +10 -0
@@ -101,6 +101,16 @@ Use `vev cmd --help` for commands and targeting options.
101101
102102 ## Development
103103
104+Use `VEV_ENV` to keep development runs separate from your installed vev and from other builds:
105+
106+```sh
107+VEV_ENV=dev go run .
108+VEV_ENV=dev go run . kill --all
109+rm -rf .dev/dev
110+```
111+
112+Each name shares one daemon and its vev-owned files under `.dev/<name>/`; use separate names to run binaries side by side. This isolates vev configuration, runtime files, durable sessions, hosts, snapshots, and logs, but it is not an OS, filesystem, or network sandbox.
113+
104114 ```sh
105115 make test # go test ./... -race
106116 make lint # goimports check, go vet
@@ -101,6 +101,16 @@ Use `vev cmd --help` for commands and targeting options.
101 101
102 ## Development102 ## Development
103 103
104+Use `VEV_ENV` to keep development runs separate from your installed vev and from other builds:
105+
106+```sh
107+VEV_ENV=dev go run .
108+VEV_ENV=dev go run . kill --all
109+rm -rf .dev/dev
110+```
111+
112+Each name shares one daemon and its vev-owned files under `.dev/<name>/`; use separate names to run binaries side by side. This isolates vev configuration, runtime files, durable sessions, hosts, snapshots, and logs, but it is not an OS, filesystem, or network sandbox.
113+
104 ```sh114 ```sh
105 make test # go test ./... -race115 make test # go test ./... -race
106 make lint # goimports check, go vet116 make lint # goimports check, go vet
M docs/configuration.md +14 -0
@@ -79,6 +79,20 @@ code.detach = DET
7979
8080 Invalid values log a warning and resolve that setting to its default on both initial load and reload.
8181
82+## Development environments
83+
84+Set `VEV_ENV` to a name such as `dev` to redirect all vev-owned paths into `.dev/<name>/` under the directory where vev was invoked. The name must be one safe segment of at most 64 ASCII letters, digits, dots, underscores, or hyphens, and must start with a letter or digit.
85+
86+```sh
87+VEV_ENV=dev go run .
88+VEV_ENV=dev go run . kill --all
89+rm -rf .dev/dev
90+```
91+
92+The selected absolute root is retained by the detached daemon and inherited vev processes even when their working directory changes. A name isolates configuration, runtime files, durable sessions, hosts, snapshots, and logs from normal XDG locations and from other names. Invocations using the same name intentionally share a daemon and files; use separate names for side-by-side binaries.
93+
94+This feature isolates vev-owned data only. It does not sandbox pane processes or provide OS, filesystem, or network isolation.
95+
8296 ## Remote hosts
8397
8498 Remote host commands, listing, and successful direct-attach learning are always active.
@@ -79,6 +79,20 @@ code.detach = DET
79 79
80 Invalid values log a warning and resolve that setting to its default on both initial load and reload.80 Invalid values log a warning and resolve that setting to its default on both initial load and reload.
81 81
82+## Development environments
83+
84+Set `VEV_ENV` to a name such as `dev` to redirect all vev-owned paths into `.dev/<name>/` under the directory where vev was invoked. The name must be one safe segment of at most 64 ASCII letters, digits, dots, underscores, or hyphens, and must start with a letter or digit.
85+
86+```sh
87+VEV_ENV=dev go run .
88+VEV_ENV=dev go run . kill --all
89+rm -rf .dev/dev
90+```
91+
92+The selected absolute root is retained by the detached daemon and inherited vev processes even when their working directory changes. A name isolates configuration, runtime files, durable sessions, hosts, snapshots, and logs from normal XDG locations and from other names. Invocations using the same name intentionally share a daemon and files; use separate names for side-by-side binaries.
93+
94+This feature isolates vev-owned data only. It does not sandbox pane processes or provide OS, filesystem, or network isolation.
95+
82 ## Remote hosts96 ## Remote hosts
83 97
84 Remote host commands, listing, and successful direct-attach learning are always active.98 Remote host commands, listing, and successful direct-attach learning are always active.
M internal/app/run.go +21 -0
@@ -145,6 +145,9 @@ func versionLine() string {
145145 // Run is the entry point invoked by main. It parses args into a command and
146146 // dispatches it.
147147 func Run(args []string) error {
148+ if err := platform.ActivateDevelopmentEnvironment(); err != nil {
149+ return err
150+ }
148151 cmd, err := parseArgs(args)
149152 if err != nil {
150153 return err
@@ -479,6 +482,17 @@ func snapshotDir() string {
479482 return filepath.Join(platform.StateDir(), "snapshots")
480483 }
481484
485+func developmentTempDirOption(ensurePrivate func(string) error) (daemon.Option, error) {
486+ dir, active := platform.DevelopmentEnvironmentTempDir()
487+ if !active {
488+ return nil, nil
489+ }
490+ if err := ensurePrivate(dir); err != nil {
491+ return nil, fmt.Errorf("vev: secure development temp directory: %w", err)
492+ }
493+ return daemon.WithTempDir(dir), nil
494+}
495+
482496 func pprofAddrIsLoopback(addr string) bool {
483497 host, _, err := net.SplitHostPort(addr)
484498 if err != nil || host == "" {
@@ -640,6 +654,13 @@ func runDaemonOwnedWithLogger(ctx context.Context, log *slog.Logger) (retErr err
640654 }
641655
642656 daemonOpts := []daemon.Option{remoteDiscoveryOpt}
657+ developmentTempOpt, err := developmentTempDirOption(safedir.EnsurePrivate)
658+ if err != nil {
659+ return err
660+ }
661+ if developmentTempOpt != nil {
662+ daemonOpts = append(daemonOpts, developmentTempOpt)
663+ }
643664 if observer != nil {
644665 daemonOpts = append(daemonOpts, daemon.WithRuntimeObserver(observer))
645666 }
@@ -145,6 +145,9 @@ func versionLine() string {
145 // Run is the entry point invoked by main. It parses args into a command and145 // Run is the entry point invoked by main. It parses args into a command and
146 // dispatches it.146 // dispatches it.
147 func Run(args []string) error {147 func Run(args []string) error {
148+ if err := platform.ActivateDevelopmentEnvironment(); err != nil {
149+ return err
150+ }
148 cmd, err := parseArgs(args)151 cmd, err := parseArgs(args)
149 if err != nil {152 if err != nil {
150 return err153 return err
@@ -479,6 +482,17 @@ func snapshotDir() string {
479 return filepath.Join(platform.StateDir(), "snapshots")482 return filepath.Join(platform.StateDir(), "snapshots")
480 }483 }
481 484
485+func developmentTempDirOption(ensurePrivate func(string) error) (daemon.Option, error) {
486+ dir, active := platform.DevelopmentEnvironmentTempDir()
487+ if !active {
488+ return nil, nil
489+ }
490+ if err := ensurePrivate(dir); err != nil {
491+ return nil, fmt.Errorf("vev: secure development temp directory: %w", err)
492+ }
493+ return daemon.WithTempDir(dir), nil
494+}
495+
482 func pprofAddrIsLoopback(addr string) bool {496 func pprofAddrIsLoopback(addr string) bool {
483 host, _, err := net.SplitHostPort(addr)497 host, _, err := net.SplitHostPort(addr)
484 if err != nil || host == "" {498 if err != nil || host == "" {
@@ -640,6 +654,13 @@ func runDaemonOwnedWithLogger(ctx context.Context, log *slog.Logger) (retErr err
640 }654 }
641 655
642 daemonOpts := []daemon.Option{remoteDiscoveryOpt}656 daemonOpts := []daemon.Option{remoteDiscoveryOpt}
657+ developmentTempOpt, err := developmentTempDirOption(safedir.EnsurePrivate)
658+ if err != nil {
659+ return err
660+ }
661+ if developmentTempOpt != nil {
662+ daemonOpts = append(daemonOpts, developmentTempOpt)
663+ }
643 if observer != nil {664 if observer != nil {
644 daemonOpts = append(daemonOpts, daemon.WithRuntimeObserver(observer))665 daemonOpts = append(daemonOpts, daemon.WithRuntimeObserver(observer))
645 }666 }
M internal/app/run_test.go +79 -0
@@ -424,6 +424,85 @@ func TestRunUDPProxyClientDeadAfterExceedsIdleTTL(t *testing.T) {
424424 require.Greater(t, udpProxyClientTransportOptions.DeadAfter, udpProxyIdleTTL)
425425 }
426426
427+func TestDevelopmentTempDirOption(t *testing.T) {
428+ absoluteBase := filepath.Join(t.TempDir(), ".dev")
429+ creatorErr := errors.New("cannot secure directory")
430+ tests := []struct {
431+ name string
432+ environment string
433+ base string
434+ creatorErr error
435+ wantPath string
436+ wantOption bool
437+ wantErr error
438+ }{
439+ {
440+ name: "inactive environment",
441+ base: absoluteBase,
442+ },
443+ {
444+ name: "active environment",
445+ environment: "work",
446+ base: absoluteBase,
447+ wantPath: filepath.Join(absoluteBase, "work", "tmp"),
448+ wantOption: true,
449+ },
450+ {
451+ name: "creator error",
452+ environment: "work",
453+ base: absoluteBase,
454+ creatorErr: creatorErr,
455+ wantPath: filepath.Join(absoluteBase, "work", "tmp"),
456+ wantErr: creatorErr,
457+ },
458+ }
459+
460+ for _, tt := range tests {
461+ t.Run(tt.name, func(t *testing.T) {
462+ t.Setenv("VEV_ENV", tt.environment)
463+ t.Setenv("VEV_ENV_ROOT", tt.base)
464+ var creatorCalls []string
465+
466+ option, err := developmentTempDirOption(func(path string) error {
467+ creatorCalls = append(creatorCalls, path)
468+ return tt.creatorErr
469+ })
470+
471+ if tt.wantErr != nil {
472+ require.ErrorIs(t, err, tt.wantErr)
473+ require.Nil(t, option)
474+ } else {
475+ require.NoError(t, err)
476+ if tt.wantOption {
477+ require.NotNil(t, option)
478+ } else {
479+ require.Nil(t, option)
480+ }
481+ }
482+ if tt.wantPath == "" {
483+ require.Empty(t, creatorCalls)
484+ } else {
485+ require.Equal(t, []string{tt.wantPath}, creatorCalls)
486+ require.True(t, filepath.IsAbs(tt.wantPath))
487+ }
488+ })
489+ }
490+}
491+
492+func TestRunActivatesDevelopmentEnvironmentBeforeParsing(t *testing.T) {
493+ t.Setenv("VEV_ENV", "../live")
494+ t.Setenv("XDG_CONFIG_HOME", "/existing/config")
495+ t.Setenv("XDG_STATE_HOME", "/existing/state")
496+ t.Setenv("XDG_RUNTIME_DIR", "/existing/runtime")
497+
498+ err := Run([]string{"unknown"})
499+ require.ErrorContains(t, err, "invalid VEV_ENV")
500+ require.NotContains(t, err.Error(), "unknown command")
501+ require.Equal(t, "/existing/config", os.Getenv("XDG_CONFIG_HOME"))
502+ require.Equal(t, "/existing/state", os.Getenv("XDG_STATE_HOME"))
503+ require.Equal(t, "/existing/runtime", os.Getenv("XDG_RUNTIME_DIR"))
504+}
505+
427506 func TestParseArgs(t *testing.T) {
428507 tests := []struct {
429508 name string
@@ -424,6 +424,85 @@ func TestRunUDPProxyClientDeadAfterExceedsIdleTTL(t *testing.T) {
424 require.Greater(t, udpProxyClientTransportOptions.DeadAfter, udpProxyIdleTTL)424 require.Greater(t, udpProxyClientTransportOptions.DeadAfter, udpProxyIdleTTL)
425 }425 }
426 426
427+func TestDevelopmentTempDirOption(t *testing.T) {
428+ absoluteBase := filepath.Join(t.TempDir(), ".dev")
429+ creatorErr := errors.New("cannot secure directory")
430+ tests := []struct {
431+ name string
432+ environment string
433+ base string
434+ creatorErr error
435+ wantPath string
436+ wantOption bool
437+ wantErr error
438+ }{
439+ {
440+ name: "inactive environment",
441+ base: absoluteBase,
442+ },
443+ {
444+ name: "active environment",
445+ environment: "work",
446+ base: absoluteBase,
447+ wantPath: filepath.Join(absoluteBase, "work", "tmp"),
448+ wantOption: true,
449+ },
450+ {
451+ name: "creator error",
452+ environment: "work",
453+ base: absoluteBase,
454+ creatorErr: creatorErr,
455+ wantPath: filepath.Join(absoluteBase, "work", "tmp"),
456+ wantErr: creatorErr,
457+ },
458+ }
459+
460+ for _, tt := range tests {
461+ t.Run(tt.name, func(t *testing.T) {
462+ t.Setenv("VEV_ENV", tt.environment)
463+ t.Setenv("VEV_ENV_ROOT", tt.base)
464+ var creatorCalls []string
465+
466+ option, err := developmentTempDirOption(func(path string) error {
467+ creatorCalls = append(creatorCalls, path)
468+ return tt.creatorErr
469+ })
470+
471+ if tt.wantErr != nil {
472+ require.ErrorIs(t, err, tt.wantErr)
473+ require.Nil(t, option)
474+ } else {
475+ require.NoError(t, err)
476+ if tt.wantOption {
477+ require.NotNil(t, option)
478+ } else {
479+ require.Nil(t, option)
480+ }
481+ }
482+ if tt.wantPath == "" {
483+ require.Empty(t, creatorCalls)
484+ } else {
485+ require.Equal(t, []string{tt.wantPath}, creatorCalls)
486+ require.True(t, filepath.IsAbs(tt.wantPath))
487+ }
488+ })
489+ }
490+}
491+
492+func TestRunActivatesDevelopmentEnvironmentBeforeParsing(t *testing.T) {
493+ t.Setenv("VEV_ENV", "../live")
494+ t.Setenv("XDG_CONFIG_HOME", "/existing/config")
495+ t.Setenv("XDG_STATE_HOME", "/existing/state")
496+ t.Setenv("XDG_RUNTIME_DIR", "/existing/runtime")
497+
498+ err := Run([]string{"unknown"})
499+ require.ErrorContains(t, err, "invalid VEV_ENV")
500+ require.NotContains(t, err.Error(), "unknown command")
501+ require.Equal(t, "/existing/config", os.Getenv("XDG_CONFIG_HOME"))
502+ require.Equal(t, "/existing/state", os.Getenv("XDG_STATE_HOME"))
503+ require.Equal(t, "/existing/runtime", os.Getenv("XDG_RUNTIME_DIR"))
504+}
505+
427 func TestParseArgs(t *testing.T) {506 func TestParseArgs(t *testing.T) {
428 tests := []struct {507 tests := []struct {
429 name string508 name string
M internal/platform/dir.go +84 -0
@@ -6,6 +6,90 @@ import (
66 "path/filepath"
77 )
88
9+const devEnvironmentRootEnv = "VEV_ENV_ROOT"
10+
11+// ActivateDevelopmentEnvironment redirects vev's XDG roots into an isolated
12+// development tree when VEV_ENV is set. The absolute .dev base is retained in
13+// VEV_ENV_ROOT so re-executed roles do not rebase after changing directory.
14+func ActivateDevelopmentEnvironment() error {
15+ name := os.Getenv("VEV_ENV")
16+ if name == "" {
17+ return nil
18+ }
19+ if !validDevelopmentEnvironmentName(name) {
20+ return fmt.Errorf("vev: invalid VEV_ENV %q (want one safe segment matching [A-Za-z0-9][A-Za-z0-9._-]{0,63})", name)
21+ }
22+
23+ base := os.Getenv(devEnvironmentRootEnv)
24+ if base == "" {
25+ absolute, err := filepath.Abs(".dev")
26+ if err != nil {
27+ return fmt.Errorf("vev: resolve VEV_ENV %q root: %w", name, err)
28+ }
29+ base = absolute
30+ } else if !filepath.IsAbs(base) {
31+ return fmt.Errorf("vev: invalid %s %q (want an absolute path)", devEnvironmentRootEnv, base)
32+ }
33+ root := filepath.Join(base, name)
34+
35+ values := map[string]string{
36+ devEnvironmentRootEnv: base,
37+ "XDG_CONFIG_HOME": filepath.Join(root, "config"),
38+ "XDG_STATE_HOME": filepath.Join(root, "state"),
39+ "XDG_RUNTIME_DIR": filepath.Join(root, "runtime"),
40+ }
41+ type previousValue struct {
42+ value string
43+ set bool
44+ }
45+ previous := make(map[string]previousValue, len(values))
46+ for key := range values {
47+ value, set := os.LookupEnv(key)
48+ previous[key] = previousValue{value: value, set: set}
49+ }
50+ for key, value := range values {
51+ if err := os.Setenv(key, value); err != nil {
52+ for rollbackKey, old := range previous {
53+ if old.set {
54+ _ = os.Setenv(rollbackKey, old.value)
55+ } else {
56+ _ = os.Unsetenv(rollbackKey)
57+ }
58+ }
59+ return fmt.Errorf("vev: activate VEV_ENV %q: %w", name, err)
60+ }
61+ }
62+ return nil
63+}
64+
65+func validDevelopmentEnvironmentName(name string) bool {
66+ if len(name) == 0 || len(name) > 64 || !asciiAlphaNumeric(name[0]) {
67+ return false
68+ }
69+ for i := 1; i < len(name); i++ {
70+ if !asciiAlphaNumeric(name[i]) && name[i] != '.' && name[i] != '_' && name[i] != '-' {
71+ return false
72+ }
73+ }
74+ return true
75+}
76+
77+func asciiAlphaNumeric(value byte) bool {
78+ return value >= 'A' && value <= 'Z' || value >= 'a' && value <= 'z' || value >= '0' && value <= '9'
79+}
80+
81+// DevelopmentEnvironmentTempDir returns the private temporary-file directory
82+// for an activated development environment. Ordinary invocations use the
83+// existing os.TempDir fallback in the daemon.
84+func DevelopmentEnvironmentTempDir() (string, bool) {
85+ name := os.Getenv("VEV_ENV")
86+ base := os.Getenv(devEnvironmentRootEnv)
87+ if name == "" || base == "" {
88+ return "", false
89+ }
90+ return filepath.Join(base, name, "tmp"), true
91+}
92+
993 // StateDir returns the directory vev writes state to: $XDG_STATE_HOME/vev if
1094 // set, else ~/.local/state/vev.
1195 func StateDir() string {
@@ -6,6 +6,90 @@ import (
6 "path/filepath"6 "path/filepath"
7 )7 )
8 8
9+const devEnvironmentRootEnv = "VEV_ENV_ROOT"
10+
11+// ActivateDevelopmentEnvironment redirects vev's XDG roots into an isolated
12+// development tree when VEV_ENV is set. The absolute .dev base is retained in
13+// VEV_ENV_ROOT so re-executed roles do not rebase after changing directory.
14+func ActivateDevelopmentEnvironment() error {
15+ name := os.Getenv("VEV_ENV")
16+ if name == "" {
17+ return nil
18+ }
19+ if !validDevelopmentEnvironmentName(name) {
20+ return fmt.Errorf("vev: invalid VEV_ENV %q (want one safe segment matching [A-Za-z0-9][A-Za-z0-9._-]{0,63})", name)
21+ }
22+
23+ base := os.Getenv(devEnvironmentRootEnv)
24+ if base == "" {
25+ absolute, err := filepath.Abs(".dev")
26+ if err != nil {
27+ return fmt.Errorf("vev: resolve VEV_ENV %q root: %w", name, err)
28+ }
29+ base = absolute
30+ } else if !filepath.IsAbs(base) {
31+ return fmt.Errorf("vev: invalid %s %q (want an absolute path)", devEnvironmentRootEnv, base)
32+ }
33+ root := filepath.Join(base, name)
34+
35+ values := map[string]string{
36+ devEnvironmentRootEnv: base,
37+ "XDG_CONFIG_HOME": filepath.Join(root, "config"),
38+ "XDG_STATE_HOME": filepath.Join(root, "state"),
39+ "XDG_RUNTIME_DIR": filepath.Join(root, "runtime"),
40+ }
41+ type previousValue struct {
42+ value string
43+ set bool
44+ }
45+ previous := make(map[string]previousValue, len(values))
46+ for key := range values {
47+ value, set := os.LookupEnv(key)
48+ previous[key] = previousValue{value: value, set: set}
49+ }
50+ for key, value := range values {
51+ if err := os.Setenv(key, value); err != nil {
52+ for rollbackKey, old := range previous {
53+ if old.set {
54+ _ = os.Setenv(rollbackKey, old.value)
55+ } else {
56+ _ = os.Unsetenv(rollbackKey)
57+ }
58+ }
59+ return fmt.Errorf("vev: activate VEV_ENV %q: %w", name, err)
60+ }
61+ }
62+ return nil
63+}
64+
65+func validDevelopmentEnvironmentName(name string) bool {
66+ if len(name) == 0 || len(name) > 64 || !asciiAlphaNumeric(name[0]) {
67+ return false
68+ }
69+ for i := 1; i < len(name); i++ {
70+ if !asciiAlphaNumeric(name[i]) && name[i] != '.' && name[i] != '_' && name[i] != '-' {
71+ return false
72+ }
73+ }
74+ return true
75+}
76+
77+func asciiAlphaNumeric(value byte) bool {
78+ return value >= 'A' && value <= 'Z' || value >= 'a' && value <= 'z' || value >= '0' && value <= '9'
79+}
80+
81+// DevelopmentEnvironmentTempDir returns the private temporary-file directory
82+// for an activated development environment. Ordinary invocations use the
83+// existing os.TempDir fallback in the daemon.
84+func DevelopmentEnvironmentTempDir() (string, bool) {
85+ name := os.Getenv("VEV_ENV")
86+ base := os.Getenv(devEnvironmentRootEnv)
87+ if name == "" || base == "" {
88+ return "", false
89+ }
90+ return filepath.Join(base, name, "tmp"), true
91+}
92+
9 // StateDir returns the directory vev writes state to: $XDG_STATE_HOME/vev if93 // StateDir returns the directory vev writes state to: $XDG_STATE_HOME/vev if
10 // set, else ~/.local/state/vev.94 // set, else ~/.local/state/vev.
11 func StateDir() string {95 func StateDir() string {
M internal/platform/dir_test.go +217 -0
@@ -7,6 +7,223 @@ import (
77 "testing"
88 )
99
10+func TestActivateDevelopmentEnvironment(t *testing.T) {
11+ originalCwd, err := os.Getwd()
12+ if err != nil {
13+ t.Fatal(err)
14+ }
15+ t.Cleanup(func() {
16+ if err := os.Chdir(originalCwd); err != nil {
17+ t.Errorf("restore working directory: %v", err)
18+ }
19+ })
20+
21+ distinctCwd := t.TempDir()
22+ tests := []struct {
23+ name string
24+ environment string
25+ cwd string
26+ wantErr bool
27+ check func(*testing.T, string)
28+ }{
29+ {
30+ name: "unset preserves existing XDG roots",
31+ check: func(t *testing.T, _ string) {
32+ t.Helper()
33+ if got := os.Getenv("XDG_CONFIG_HOME"); got != "/existing/config" {
34+ t.Errorf("XDG_CONFIG_HOME = %q", got)
35+ }
36+ if got := os.Getenv("XDG_STATE_HOME"); got != "/existing/state" {
37+ t.Errorf("XDG_STATE_HOME = %q", got)
38+ }
39+ if got := os.Getenv("XDG_RUNTIME_DIR"); got != "/existing/runtime" {
40+ t.Errorf("XDG_RUNTIME_DIR = %q", got)
41+ }
42+ },
43+ },
44+ {
45+ name: "valid name overrides XDG roots",
46+ environment: "dev",
47+ check: func(t *testing.T, cwd string) {
48+ t.Helper()
49+ root := filepath.Join(cwd, ".dev", "dev")
50+ if got := os.Getenv("XDG_CONFIG_HOME"); got != filepath.Join(root, "config") {
51+ t.Errorf("XDG_CONFIG_HOME = %q", got)
52+ }
53+ if got := os.Getenv("XDG_STATE_HOME"); got != filepath.Join(root, "state") {
54+ t.Errorf("XDG_STATE_HOME = %q", got)
55+ }
56+ if got := os.Getenv("XDG_RUNTIME_DIR"); got != filepath.Join(root, "runtime") {
57+ t.Errorf("XDG_RUNTIME_DIR = %q", got)
58+ }
59+ if got, want := os.Getenv("VEV_ENV_ROOT"), filepath.Join(cwd, ".dev"); got != want {
60+ t.Errorf("VEV_ENV_ROOT = %q, want %q", got, want)
61+ }
62+ if _, err := os.Stat(root); !os.IsNotExist(err) {
63+ t.Errorf("activation created root or returned unexpected error: %v", err)
64+ }
65+ },
66+ },
67+ {
68+ name: "resolved root survives cwd change",
69+ environment: "persisted",
70+ check: func(t *testing.T, cwd string) {
71+ t.Helper()
72+ root := filepath.Join(cwd, ".dev", "persisted")
73+ other := t.TempDir()
74+ if err := os.Chdir(other); err != nil {
75+ t.Fatal(err)
76+ }
77+ if err := ActivateDevelopmentEnvironment(); err != nil {
78+ t.Fatal(err)
79+ }
80+ if got := os.Getenv("XDG_STATE_HOME"); got != filepath.Join(root, "state") {
81+ t.Errorf("XDG_STATE_HOME after cwd change = %q", got)
82+ }
83+ },
84+ },
85+ {name: "distinct name alpha", environment: "alpha", cwd: distinctCwd},
86+ {name: "distinct name beta", environment: "beta", cwd: distinctCwd},
87+ {name: "reject traversal", environment: "../live", wantErr: true},
88+ {name: "reject separator", environment: "a/b", wantErr: true},
89+ {name: "reject whitespace", environment: "two words", wantErr: true},
90+ {name: "reject leading punctuation", environment: ".hidden", wantErr: true},
91+ {name: "reject 65 characters", environment: strings.Repeat("a", 65), wantErr: true},
92+ }
93+
94+ for _, tt := range tests {
95+ t.Run(tt.name, func(t *testing.T) {
96+ cwd := tt.cwd
97+ if cwd == "" {
98+ cwd = t.TempDir()
99+ }
100+ if err := os.Chdir(cwd); err != nil {
101+ t.Fatal(err)
102+ }
103+ canonicalCwd, getwdErr := os.Getwd()
104+ if getwdErr != nil {
105+ t.Fatal(getwdErr)
106+ }
107+ t.Setenv("VEV_ENV", tt.environment)
108+ t.Setenv("VEV_ENV_ROOT", "")
109+ t.Setenv("XDG_CONFIG_HOME", "/existing/config")
110+ t.Setenv("XDG_STATE_HOME", "/existing/state")
111+ t.Setenv("XDG_RUNTIME_DIR", "/existing/runtime")
112+
113+ err := ActivateDevelopmentEnvironment()
114+ if tt.wantErr {
115+ if err == nil || !strings.Contains(err.Error(), "VEV_ENV") {
116+ t.Fatalf("ActivateDevelopmentEnvironment() error = %v", err)
117+ }
118+ for name, want := range map[string]string{
119+ "XDG_CONFIG_HOME": "/existing/config",
120+ "XDG_STATE_HOME": "/existing/state",
121+ "XDG_RUNTIME_DIR": "/existing/runtime",
122+ "VEV_ENV_ROOT": "",
123+ } {
124+ if got := os.Getenv(name); got != want {
125+ t.Errorf("%s changed to %q, want %q", name, got, want)
126+ }
127+ }
128+ return
129+ }
130+ if err != nil {
131+ t.Fatal(err)
132+ }
133+ if tt.check != nil {
134+ tt.check(t, canonicalCwd)
135+ }
136+ })
137+ }
138+}
139+
140+func TestDevelopmentEnvironmentTempDir(t *testing.T) {
141+ absoluteBase := filepath.Join(t.TempDir(), ".dev")
142+ tests := []struct {
143+ name string
144+ environment string
145+ base string
146+ want string
147+ wantActive bool
148+ }{
149+ {name: "inactive without environment", base: absoluteBase},
150+ {name: "inactive without inherited base", environment: "work"},
151+ {
152+ name: "active environment",
153+ environment: "work",
154+ base: absoluteBase,
155+ want: filepath.Join(absoluteBase, "work", "tmp"),
156+ wantActive: true,
157+ },
158+ }
159+
160+ for _, tt := range tests {
161+ t.Run(tt.name, func(t *testing.T) {
162+ t.Setenv("VEV_ENV", tt.environment)
163+ t.Setenv("VEV_ENV_ROOT", tt.base)
164+
165+ got, active := DevelopmentEnvironmentTempDir()
166+ if got != tt.want || active != tt.wantActive {
167+ t.Fatalf("DevelopmentEnvironmentTempDir() = (%q, %v), want (%q, %v)", got, active, tt.want, tt.wantActive)
168+ }
169+ if active && !filepath.IsAbs(got) {
170+ t.Errorf("DevelopmentEnvironmentTempDir() = %q, want absolute path", got)
171+ }
172+ })
173+ }
174+}
175+
176+func TestActivateDevelopmentEnvironmentRecomputesProfileFromInheritedBase(t *testing.T) {
177+ originalCwd, err := os.Getwd()
178+ if err != nil {
179+ t.Fatal(err)
180+ }
181+ t.Cleanup(func() {
182+ if err := os.Chdir(originalCwd); err != nil {
183+ t.Errorf("restore working directory: %v", err)
184+ }
185+ })
186+
187+ if err := os.Chdir(t.TempDir()); err != nil {
188+ t.Fatal(err)
189+ }
190+ invocationCwd, err := os.Getwd()
191+ if err != nil {
192+ t.Fatal(err)
193+ }
194+ t.Setenv("VEV_ENV", "alpha")
195+ t.Setenv("VEV_ENV_ROOT", "")
196+ if err := ActivateDevelopmentEnvironment(); err != nil {
197+ t.Fatal(err)
198+ }
199+ base := filepath.Join(invocationCwd, ".dev")
200+ if got := os.Getenv("VEV_ENV_ROOT"); got != base {
201+ t.Fatalf("VEV_ENV_ROOT = %q, want %q", got, base)
202+ }
203+
204+ if err := os.Chdir(t.TempDir()); err != nil {
205+ t.Fatal(err)
206+ }
207+ if err := os.Setenv("VEV_ENV", "beta"); err != nil {
208+ t.Fatal(err)
209+ }
210+ if err := ActivateDevelopmentEnvironment(); err != nil {
211+ t.Fatal(err)
212+ }
213+
214+ betaRoot := filepath.Join(base, "beta")
215+ for name, want := range map[string]string{
216+ "VEV_ENV_ROOT": base,
217+ "XDG_CONFIG_HOME": filepath.Join(betaRoot, "config"),
218+ "XDG_STATE_HOME": filepath.Join(betaRoot, "state"),
219+ "XDG_RUNTIME_DIR": filepath.Join(betaRoot, "runtime"),
220+ } {
221+ if got := os.Getenv(name); got != want {
222+ t.Errorf("%s = %q, want %q", name, got, want)
223+ }
224+ }
225+}
226+
10227 func TestStateDir(t *testing.T) {
11228 tests := []struct {
12229 name string
@@ -7,6 +7,223 @@ import (
7 "testing"7 "testing"
8 )8 )
9 9
10+func TestActivateDevelopmentEnvironment(t *testing.T) {
11+ originalCwd, err := os.Getwd()
12+ if err != nil {
13+ t.Fatal(err)
14+ }
15+ t.Cleanup(func() {
16+ if err := os.Chdir(originalCwd); err != nil {
17+ t.Errorf("restore working directory: %v", err)
18+ }
19+ })
20+
21+ distinctCwd := t.TempDir()
22+ tests := []struct {
23+ name string
24+ environment string
25+ cwd string
26+ wantErr bool
27+ check func(*testing.T, string)
28+ }{
29+ {
30+ name: "unset preserves existing XDG roots",
31+ check: func(t *testing.T, _ string) {
32+ t.Helper()
33+ if got := os.Getenv("XDG_CONFIG_HOME"); got != "/existing/config" {
34+ t.Errorf("XDG_CONFIG_HOME = %q", got)
35+ }
36+ if got := os.Getenv("XDG_STATE_HOME"); got != "/existing/state" {
37+ t.Errorf("XDG_STATE_HOME = %q", got)
38+ }
39+ if got := os.Getenv("XDG_RUNTIME_DIR"); got != "/existing/runtime" {
40+ t.Errorf("XDG_RUNTIME_DIR = %q", got)
41+ }
42+ },
43+ },
44+ {
45+ name: "valid name overrides XDG roots",
46+ environment: "dev",
47+ check: func(t *testing.T, cwd string) {
48+ t.Helper()
49+ root := filepath.Join(cwd, ".dev", "dev")
50+ if got := os.Getenv("XDG_CONFIG_HOME"); got != filepath.Join(root, "config") {
51+ t.Errorf("XDG_CONFIG_HOME = %q", got)
52+ }
53+ if got := os.Getenv("XDG_STATE_HOME"); got != filepath.Join(root, "state") {
54+ t.Errorf("XDG_STATE_HOME = %q", got)
55+ }
56+ if got := os.Getenv("XDG_RUNTIME_DIR"); got != filepath.Join(root, "runtime") {
57+ t.Errorf("XDG_RUNTIME_DIR = %q", got)
58+ }
59+ if got, want := os.Getenv("VEV_ENV_ROOT"), filepath.Join(cwd, ".dev"); got != want {
60+ t.Errorf("VEV_ENV_ROOT = %q, want %q", got, want)
61+ }
62+ if _, err := os.Stat(root); !os.IsNotExist(err) {
63+ t.Errorf("activation created root or returned unexpected error: %v", err)
64+ }
65+ },
66+ },
67+ {
68+ name: "resolved root survives cwd change",
69+ environment: "persisted",
70+ check: func(t *testing.T, cwd string) {
71+ t.Helper()
72+ root := filepath.Join(cwd, ".dev", "persisted")
73+ other := t.TempDir()
74+ if err := os.Chdir(other); err != nil {
75+ t.Fatal(err)
76+ }
77+ if err := ActivateDevelopmentEnvironment(); err != nil {
78+ t.Fatal(err)
79+ }
80+ if got := os.Getenv("XDG_STATE_HOME"); got != filepath.Join(root, "state") {
81+ t.Errorf("XDG_STATE_HOME after cwd change = %q", got)
82+ }
83+ },
84+ },
85+ {name: "distinct name alpha", environment: "alpha", cwd: distinctCwd},
86+ {name: "distinct name beta", environment: "beta", cwd: distinctCwd},
87+ {name: "reject traversal", environment: "../live", wantErr: true},
88+ {name: "reject separator", environment: "a/b", wantErr: true},
89+ {name: "reject whitespace", environment: "two words", wantErr: true},
90+ {name: "reject leading punctuation", environment: ".hidden", wantErr: true},
91+ {name: "reject 65 characters", environment: strings.Repeat("a", 65), wantErr: true},
92+ }
93+
94+ for _, tt := range tests {
95+ t.Run(tt.name, func(t *testing.T) {
96+ cwd := tt.cwd
97+ if cwd == "" {
98+ cwd = t.TempDir()
99+ }
100+ if err := os.Chdir(cwd); err != nil {
101+ t.Fatal(err)
102+ }
103+ canonicalCwd, getwdErr := os.Getwd()
104+ if getwdErr != nil {
105+ t.Fatal(getwdErr)
106+ }
107+ t.Setenv("VEV_ENV", tt.environment)
108+ t.Setenv("VEV_ENV_ROOT", "")
109+ t.Setenv("XDG_CONFIG_HOME", "/existing/config")
110+ t.Setenv("XDG_STATE_HOME", "/existing/state")
111+ t.Setenv("XDG_RUNTIME_DIR", "/existing/runtime")
112+
113+ err := ActivateDevelopmentEnvironment()
114+ if tt.wantErr {
115+ if err == nil || !strings.Contains(err.Error(), "VEV_ENV") {
116+ t.Fatalf("ActivateDevelopmentEnvironment() error = %v", err)
117+ }
118+ for name, want := range map[string]string{
119+ "XDG_CONFIG_HOME": "/existing/config",
120+ "XDG_STATE_HOME": "/existing/state",
121+ "XDG_RUNTIME_DIR": "/existing/runtime",
122+ "VEV_ENV_ROOT": "",
123+ } {
124+ if got := os.Getenv(name); got != want {
125+ t.Errorf("%s changed to %q, want %q", name, got, want)
126+ }
127+ }
128+ return
129+ }
130+ if err != nil {
131+ t.Fatal(err)
132+ }
133+ if tt.check != nil {
134+ tt.check(t, canonicalCwd)
135+ }
136+ })
137+ }
138+}
139+
140+func TestDevelopmentEnvironmentTempDir(t *testing.T) {
141+ absoluteBase := filepath.Join(t.TempDir(), ".dev")
142+ tests := []struct {
143+ name string
144+ environment string
145+ base string
146+ want string
147+ wantActive bool
148+ }{
149+ {name: "inactive without environment", base: absoluteBase},
150+ {name: "inactive without inherited base", environment: "work"},
151+ {
152+ name: "active environment",
153+ environment: "work",
154+ base: absoluteBase,
155+ want: filepath.Join(absoluteBase, "work", "tmp"),
156+ wantActive: true,
157+ },
158+ }
159+
160+ for _, tt := range tests {
161+ t.Run(tt.name, func(t *testing.T) {
162+ t.Setenv("VEV_ENV", tt.environment)
163+ t.Setenv("VEV_ENV_ROOT", tt.base)
164+
165+ got, active := DevelopmentEnvironmentTempDir()
166+ if got != tt.want || active != tt.wantActive {
167+ t.Fatalf("DevelopmentEnvironmentTempDir() = (%q, %v), want (%q, %v)", got, active, tt.want, tt.wantActive)
168+ }
169+ if active && !filepath.IsAbs(got) {
170+ t.Errorf("DevelopmentEnvironmentTempDir() = %q, want absolute path", got)
171+ }
172+ })
173+ }
174+}
175+
176+func TestActivateDevelopmentEnvironmentRecomputesProfileFromInheritedBase(t *testing.T) {
177+ originalCwd, err := os.Getwd()
178+ if err != nil {
179+ t.Fatal(err)
180+ }
181+ t.Cleanup(func() {
182+ if err := os.Chdir(originalCwd); err != nil {
183+ t.Errorf("restore working directory: %v", err)
184+ }
185+ })
186+
187+ if err := os.Chdir(t.TempDir()); err != nil {
188+ t.Fatal(err)
189+ }
190+ invocationCwd, err := os.Getwd()
191+ if err != nil {
192+ t.Fatal(err)
193+ }
194+ t.Setenv("VEV_ENV", "alpha")
195+ t.Setenv("VEV_ENV_ROOT", "")
196+ if err := ActivateDevelopmentEnvironment(); err != nil {
197+ t.Fatal(err)
198+ }
199+ base := filepath.Join(invocationCwd, ".dev")
200+ if got := os.Getenv("VEV_ENV_ROOT"); got != base {
201+ t.Fatalf("VEV_ENV_ROOT = %q, want %q", got, base)
202+ }
203+
204+ if err := os.Chdir(t.TempDir()); err != nil {
205+ t.Fatal(err)
206+ }
207+ if err := os.Setenv("VEV_ENV", "beta"); err != nil {
208+ t.Fatal(err)
209+ }
210+ if err := ActivateDevelopmentEnvironment(); err != nil {
211+ t.Fatal(err)
212+ }
213+
214+ betaRoot := filepath.Join(base, "beta")
215+ for name, want := range map[string]string{
216+ "VEV_ENV_ROOT": base,
217+ "XDG_CONFIG_HOME": filepath.Join(betaRoot, "config"),
218+ "XDG_STATE_HOME": filepath.Join(betaRoot, "state"),
219+ "XDG_RUNTIME_DIR": filepath.Join(betaRoot, "runtime"),
220+ } {
221+ if got := os.Getenv(name); got != want {
222+ t.Errorf("%s = %q, want %q", name, got, want)
223+ }
224+ }
225+}
226+
10 func TestStateDir(t *testing.T) {227 func TestStateDir(t *testing.T) {
11 tests := []struct {228 tests := []struct {
12 name string229 name string

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close