Skip to content

Commit 645fa91

Browse files
committed
Refine workspace dependency seams
1 parent 65e50e0 commit 645fa91

9 files changed

Lines changed: 270 additions & 56 deletions

File tree

docs/spec-review.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -169,10 +169,12 @@ Incremental progress:
169169
- `Workspace` carries a narrow Git runner interface, with the production implementation isolated in `realGitRunner`. Workspace-owned Git operations route through that boundary, and a focused test verifies runner injection without creating a Git repository.
170170
- `Workspace` carries a narrow working-tree filesystem interface for live file reads and writes. Materialization uses resolved absolute worktree paths instead of CWD-relative plan paths, with regression coverage for dirty-path materialization while the process CWD is elsewhere.
171171
- `Workspace` carries a narrow temp-store interface for Git object directories and synthetic index files. Planning and dry-run temp allocations are injectable, isolated under a test-owned directory in regression coverage, and cleaned up after use.
172+
- Script loading uses an injectable `scriptSource`, so command-line `run` no longer embeds direct script file reads in CLI argument handling.
173+
- `Workspace` carries a narrow path-resolution interface for process CWD and symlink resolution. Path resolution can be tested with fake symlink targets instead of real filesystem links.
172174

173-
Remaining implementation gap: path resolution and script reading still use process OS APIs directly. The production Git runner shells out to Git, and most tests use real temporary Git repositories. This means:
175+
Remaining implementation gap: the production Git runner shells out to Git, and most tests use real temporary Git repositories. This means:
174176
- Tests cannot model scenarios without touching the filesystem.
175-
- The architecture diagram's separation between planner, snapshot store, and git backend is not reflected in the code.
177+
- The architecture diagram's separation between planner, snapshot store, and git backend is only partially reflected in the code.
176178

177179
---
178180

internal/etch/cli.go

Lines changed: 0 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,6 @@ import (
55
"errors"
66
"fmt"
77
"io"
8-
"os"
9-
"path/filepath"
108
"strings"
119

1210
cli "github.com/urfave/cli/v3"
@@ -176,30 +174,6 @@ func executeStatements(opts GlobalOptions, stmts []Statement, stdout, stderr io.
176174
return exec.Run(ops, stdout, stderr)
177175
}
178176

179-
func ParseScriptAt(cwd, path string) ([]Statement, error) {
180-
var data []byte
181-
var err error
182-
name := path
183-
if path == "-" {
184-
name = "<stdin>"
185-
data, err = readStdin()
186-
} else {
187-
readPath := path
188-
if cwd != "" && !filepath.IsAbs(readPath) {
189-
readPath = filepath.Join(cwd, readPath)
190-
}
191-
data, err = os.ReadFile(readPath)
192-
}
193-
if err != nil {
194-
return nil, failf("%s", err)
195-
}
196-
return ParseScriptBytes(name, data)
197-
}
198-
199-
var readStdin = func() ([]byte, error) {
200-
return io.ReadAll(os.Stdin)
201-
}
202-
203177
func completeShell(_ context.Context, cmd *cli.Command) {
204178
args := cmd.Args().Slice()
205179
if len(args) > 0 && strings.HasPrefix(args[len(args)-1], "-") {

internal/etch/git.go

Lines changed: 35 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ package etch
33
import (
44
"bytes"
55
"fmt"
6-
"os"
76
"path/filepath"
87
"strings"
98
)
@@ -18,48 +17,59 @@ type Workspace struct {
1817
git gitRunner
1918
worktree workingTreeFS
2019
temp workspaceTempStore
20+
paths workspacePaths
2121
}
2222

2323
type workspaceDeps struct {
2424
git gitRunner
2525
worktree workingTreeFS
2626
temp workspaceTempStore
27+
paths workspacePaths
28+
}
29+
30+
func (d workspaceDeps) withDefaults() workspaceDeps {
31+
if d.git == nil {
32+
d.git = realGitRunner{}
33+
}
34+
if d.worktree == nil {
35+
d.worktree = osWorkingTreeFS{}
36+
}
37+
if d.temp == nil {
38+
d.temp = osWorkspaceTempStore{}
39+
}
40+
if d.paths == nil {
41+
d.paths = osWorkspacePaths{}
42+
}
43+
return d
2744
}
2845

2946
// OpenWorkspace opens the process working directory. Prefer OpenWorkspaceAt
3047
// when the caller already has an explicit directory.
3148
func OpenWorkspace(untracked bool) (*Workspace, error) {
32-
cwd, err := os.Getwd()
49+
return openWorkspace(untracked, workspaceDeps{})
50+
}
51+
52+
func openWorkspace(untracked bool, deps workspaceDeps) (*Workspace, error) {
53+
deps = deps.withDefaults()
54+
cwd, err := deps.paths.getwd()
3355
if err != nil {
3456
return nil, err
3557
}
36-
return OpenWorkspaceAt(cwd, untracked)
58+
return openWorkspaceAt(cwd, untracked, deps)
3759
}
3860

3961
func OpenWorkspaceAt(cwd string, untracked bool) (*Workspace, error) {
40-
return openWorkspaceAt(cwd, untracked, realGitRunner{})
41-
}
42-
43-
func openWorkspaceAt(cwd string, untracked bool, runner gitRunner) (*Workspace, error) {
44-
return openWorkspaceAtWithDeps(cwd, untracked, workspaceDeps{git: runner})
62+
return openWorkspaceAt(cwd, untracked, workspaceDeps{})
4563
}
4664

47-
func openWorkspaceAtWithDeps(cwd string, untracked bool, deps workspaceDeps) (*Workspace, error) {
48-
if deps.git == nil {
49-
deps.git = realGitRunner{}
50-
}
51-
if deps.worktree == nil {
52-
deps.worktree = osWorkingTreeFS{}
53-
}
54-
if deps.temp == nil {
55-
deps.temp = osWorkspaceTempStore{}
56-
}
57-
abs, err := filepath.Abs(cwd)
65+
func openWorkspaceAt(cwd string, untracked bool, deps workspaceDeps) (*Workspace, error) {
66+
deps = deps.withDefaults()
67+
abs, err := deps.paths.abs(cwd)
5868
if err != nil {
5969
return nil, err
6070
}
6171
cwd = abs
62-
if real, err := filepath.EvalSymlinks(cwd); err == nil {
72+
if real, err := deps.paths.evalSymlinks(cwd); err == nil {
6373
cwd = real
6474
} else {
6575
return nil, failf("cannot resolve working directory %s: %v", cwd, err)
@@ -69,7 +79,7 @@ func openWorkspaceAtWithDeps(cwd string, untracked bool, deps workspaceDeps) (*W
6979
return nil, failf("not inside a git worktree")
7080
}
7181
root := strings.TrimSpace(string(rootBytes))
72-
if real, err := filepath.EvalSymlinks(root); err == nil {
82+
if real, err := deps.paths.evalSymlinks(root); err == nil {
7383
root = real
7484
}
7585
headBytes, err := deps.git.output(cwd, nil, "rev-parse", "--verify", "HEAD")
@@ -85,7 +95,7 @@ func openWorkspaceAtWithDeps(cwd string, untracked bool, deps workspaceDeps) (*W
8595
if err == nil {
8696
ref = strings.TrimSpace(string(refBytes))
8797
}
88-
return &Workspace{CWD: cwd, Root: root, Head: head, Ref: ref, Unborn: unborn, Untracked: untracked, git: deps.git, worktree: deps.worktree, temp: deps.temp}, nil
98+
return &Workspace{CWD: cwd, Root: root, Head: head, Ref: ref, Unborn: unborn, Untracked: untracked, git: deps.git, worktree: deps.worktree, temp: deps.temp, paths: deps.paths}, nil
8999
}
90100

91101
type ResolvedPath struct {
@@ -120,14 +130,15 @@ func (w *Workspace) Resolve(input string, mayBeMissing bool, finalSymlink bool)
120130
if mayBeMissing {
121131
checkAbs = filepath.Dir(abs)
122132
}
133+
paths := w.pathResolver()
123134
if finalSymlink && !mayBeMissing {
124-
if real, err := filepath.EvalSymlinks(abs); err == nil {
135+
if real, err := paths.evalSymlinks(abs); err == nil {
125136
checkAbs = real
126137
abs = real
127138
} else if !isNoSuch(err) {
128139
return ResolvedPath{}, failf("%s: %v", input, err)
129140
}
130-
} else if real, err := filepath.EvalSymlinks(checkAbs); err == nil {
141+
} else if real, err := paths.evalSymlinks(checkAbs); err == nil {
131142
checkAbs = real
132143
} else if !isNoSuch(err) {
133144
return ResolvedPath{}, failf("%s: %v", input, err)

internal/etch/git_object_store.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ func (w *Workspace) objectDir() (string, error) {
5757
if !filepath.IsAbs(path) {
5858
path = filepath.Join(w.CWD, path)
5959
}
60-
if real, err := filepath.EvalSymlinks(path); err == nil {
60+
if real, err := w.pathResolver().evalSymlinks(path); err == nil {
6161
path = real
6262
}
6363
return path, nil

internal/etch/git_object_store_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ func TestPlanAndDryRunUseInjectedTempStore(t *testing.T) {
1313
tempRoot := t.TempDir()
1414
temp := &recordingTempStore{root: tempRoot}
1515

16-
w, err := openWorkspaceAtWithDeps(dir, false, workspaceDeps{
16+
w, err := openWorkspaceAt(dir, false, workspaceDeps{
1717
git: realGitRunner{},
1818
worktree: osWorkingTreeFS{},
1919
temp: temp,

internal/etch/script_source.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
package etch
2+
3+
import (
4+
"io"
5+
"os"
6+
"path/filepath"
7+
)
8+
9+
// scriptSource is the IO boundary for etch scripts. The parser works on bytes;
10+
// this source keeps command-line script loading injectable and separate from
11+
// CLI argument handling.
12+
type scriptSource interface {
13+
readFile(path string) ([]byte, error)
14+
readStdin() ([]byte, error)
15+
}
16+
17+
type osScriptSource struct{}
18+
19+
func ParseScriptAt(cwd, path string) ([]Statement, error) {
20+
return parseScriptAt(cwd, path, osScriptSource{})
21+
}
22+
23+
func parseScriptAt(cwd, path string, source scriptSource) ([]Statement, error) {
24+
if source == nil {
25+
source = osScriptSource{}
26+
}
27+
var data []byte
28+
var err error
29+
name := path
30+
if path == "-" {
31+
name = "<stdin>"
32+
data, err = source.readStdin()
33+
} else {
34+
readPath := path
35+
if cwd != "" && !filepath.IsAbs(readPath) {
36+
readPath = filepath.Join(cwd, readPath)
37+
}
38+
data, err = source.readFile(readPath)
39+
}
40+
if err != nil {
41+
return nil, failf("%s", err)
42+
}
43+
return ParseScriptBytes(name, data)
44+
}
45+
46+
func (osScriptSource) readFile(path string) ([]byte, error) {
47+
return os.ReadFile(path)
48+
}
49+
50+
func (osScriptSource) readStdin() ([]byte, error) {
51+
return readStdin()
52+
}
53+
54+
var readStdin = func() ([]byte, error) {
55+
return io.ReadAll(os.Stdin)
56+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
package etch
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"path/filepath"
7+
"testing"
8+
)
9+
10+
func TestParseScriptAtUsesInjectedScriptSource(t *testing.T) {
11+
dir := t.TempDir()
12+
path := filepath.Join(dir, "ops.etch")
13+
source := &fakeScriptSource{
14+
files: map[string][]byte{
15+
path: []byte("set state.json status complete\n"),
16+
},
17+
}
18+
19+
stmts, err := parseScriptAt(dir, "ops.etch", source)
20+
if err != nil {
21+
t.Fatal(err)
22+
}
23+
if got, want := source.reads, []string{path}; len(got) != len(want) || got[0] != want[0] {
24+
t.Fatalf("script reads = %v, want %v", got, want)
25+
}
26+
if len(stmts) != 1 || stmts[0].Loc.Name != "ops.etch" || stmts[0].Tokens[0] != "set" {
27+
t.Fatalf("statements = %#v", stmts)
28+
}
29+
}
30+
31+
func TestParseScriptAtUsesInjectedStdin(t *testing.T) {
32+
source := &fakeScriptSource{stdin: []byte("set stdin.json x 1\n")}
33+
34+
stmts, err := parseScriptAt("ignored", "-", source)
35+
if err != nil {
36+
t.Fatal(err)
37+
}
38+
if !source.stdinRead {
39+
t.Fatal("stdin was not read")
40+
}
41+
if len(stmts) != 1 || stmts[0].Loc.Name != "<stdin>" || stmts[0].Tokens[1] != "stdin.json" {
42+
t.Fatalf("statements = %#v", stmts)
43+
}
44+
}
45+
46+
type fakeScriptSource struct {
47+
files map[string][]byte
48+
stdin []byte
49+
reads []string
50+
stdinRead bool
51+
}
52+
53+
func (s *fakeScriptSource) readFile(path string) ([]byte, error) {
54+
s.reads = append(s.reads, path)
55+
b, ok := s.files[path]
56+
if !ok {
57+
return nil, fmt.Errorf("%w: %s", os.ErrNotExist, path)
58+
}
59+
return append([]byte(nil), b...), nil
60+
}
61+
62+
func (s *fakeScriptSource) readStdin() ([]byte, error) {
63+
s.stdinRead = true
64+
return append([]byte(nil), s.stdin...), nil
65+
}

internal/etch/workspace_paths.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package etch
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
)
7+
8+
// workspacePaths is the boundary for process-dependent path resolution. The
9+
// rest of Workspace can still use filepath's pure lexical operations directly,
10+
// but current-directory and symlink resolution go through this interface.
11+
type workspacePaths interface {
12+
getwd() (string, error)
13+
abs(path string) (string, error)
14+
evalSymlinks(path string) (string, error)
15+
}
16+
17+
type osWorkspacePaths struct{}
18+
19+
func (w *Workspace) pathResolver() workspacePaths {
20+
if w.paths != nil {
21+
return w.paths
22+
}
23+
return osWorkspacePaths{}
24+
}
25+
26+
func (osWorkspacePaths) getwd() (string, error) {
27+
return os.Getwd()
28+
}
29+
30+
func (osWorkspacePaths) abs(path string) (string, error) {
31+
return filepath.Abs(path)
32+
}
33+
34+
func (osWorkspacePaths) evalSymlinks(path string) (string, error) {
35+
return filepath.EvalSymlinks(path)
36+
}

0 commit comments

Comments
 (0)