GitRoot

Craft your forge, Build your project, Grow your community freely
  1---
  2title: Modifying the API in the SDK Plugin
  3og:title: Modifying the API in the SDK Plugin
  4og:description: Explaining how the SDK works through a concrete example add mv function.
  5description: Explaining how the SDK works through a concrete example add mv function.
  6og:image: ""
  7date: 2026/04/06
  8emoji: ✨
  9---
 10
 11# Modifying the API in the SDK Plugin
 12
 13The last feature for version 0.4 is about adding `mv` (move) and `cp` (copy) functions to the SDK plugin. I'm taking advantage of this development to explain how the plumbing works under the hood.
 14
 15Right now, the SDK plugin is undocumented because there are a lot of breaking changes coming. Every time I develop a new plugin, I discover better ways to do things, and the SDK needs to adapt.
 16
 17For example, when I added `apex_code` (a plugin that takes code as a string and returns syntax-highlighted HTML), I wondered: "Is this a standalone plugin?". Not really, because there's no file to manipulate, neither in input nor output. Instead, it manages blocks inside an existing file. The SDK wasn't originally built with that use case in mind.
 18
 19Anyway, I think explaining how the SDK works through a concrete example will be helpful, if not for you, at least for me.
 20
 21## What is the SDK's job?
 22
 23The SDK exists to pass data from GitRoot (the host) to the plugin (the guest). As you know, GitRoot uses WebAssembly (WASM) to manage plugins. Unfortunately, WASM is still a bit low-level to implement: between the host and the guest, only integers can travel.
 24
 25Wait, only integers?
 26
 27Yes. So, how do we send a string? The host can store any kind of data in the guest's memory. GitRoot stores a string, retrieves the pointer (the memory address) and the size, and sends those two integers to the guest. The guest then retrieves the string from its own memory. It's a complex dance just for a simple string. The SDK is here to hide all that mess.
 28
 29When you build a plugin, you just implement an interface. The SDK handles the rest: you receive objects and strings, and you send back data without ever worrying about the underlying WASM pointers.
 30
 31## What does a concrete implementation look like?
 32
 33When I add a new function, I always start with the host side. All functions are located in [runtime.go](../app/server/plugin/runtime.go).
 34
 35We need to register the function in [wazero](https://github.com/wazero/wazero), our WASM runtime. I add two versions because I haven't found a clean way to bridge the difference between AssemblyScript and Go/Rust yet. Hopefully, I'll figure that out one day.
 36
 37```go
 38r.wazRun.
 39    NewFunctionBuilder().WithFunc(r.MoveFileBuilder(false)).Export("moveFile").
 40    NewFunctionBuilder().WithFunc(r.MoveFileBuilder(true)).Export("moveFileAS")
 41```
 42
 43Then I implement the builder:
 44
 45```go
 46func (r *runtime) MoveFileBuilder(forAS bool) interface{} {
 47	if !forAS {
 48		return func(_ context.Context, m api.Module, ptr, size, ptr2, size2 uint32) {
 49			// from ptr/size to string or object
 50			r.moveFile(fromPath, toPath)
 51		}
 52	} else {
 53		return func(_ context.Context, m api.Module, ptr, ptr2 uint32) {
 54			// from ptr to string or object
 55			r.moveFile(fromPath, toPath)
 56		}
 57	}
 58}
 59```
 60
 61> Yeah, I know. As a Gopher, seeing `interface{}` as a return type and all these uint32 feels wrong. But hey, we're not here for a code review, we're here to add an API. If you have a cleaner way, feel free to submit a graft!
 62
 63We want to allow any plugin to move a file. Let's define the internal `moveFile` function:
 64
 65```go
 66func (r *runtime) moveFile(fromPath, toPath string) error {
 67	//need to do something...
 68}
 69```
 70
 71Actually, there's a catch, GitRoot uses three internal filesystems: one for the git worktree, one for web content, and one for the cache. Even if they are abstracted under one root, the code often expects a specific FS. I'll probably clean this up later, but for now:
 72
 73```go
 74func (r *runtime) moveFile(fromFs, fromPath, toFs, toPath string) error {
 75	//need to do something...
 76}
 77```
 78
 79Now your plugin can call `moveFile("cache", "artifacts/myBin", "web", "releases/myBin-v1.0")` to publish an artifact, or `moveFile("web", "index.html", "web", "old/index.html")` to back up a file.
 80
 81I won't bore you with the implementation logic here. If you're curious, check the [code](../app/server/plugin/runtime.go).
 82
 83Here is how the final builder looks, converting pointers to strings:
 84
 85```go
 86func (r *runtime) MoveFileBuilder(forAS bool) interface{} {
 87	if !forAS {
 88		return func(_ context.Context, m api.Module, fromFsPtr, fromFsSize, fromPathPtr, fromGlobPathSize, toFsPtr, toFsSize, toPathPtr, toPathSize uint32) uint64 {
 89			fromFs, err := r.readString(m, "MoveFile fromFs", fromFsPtr, fromFsSize)
 90			if err != nil {
 91				return r.sendEmptyOrError(m, err)
 92			}
 93			fromPath, err := r.readString(m, "MoveFile fromGlobPath", fromPathPtr, fromGlobPathSize)
 94			if err != nil {
 95				return r.sendEmptyOrError(m, err)
 96			}
 97			toFs, err := r.readString(m, "MoveFile toFs", toFsPtr, toFsSize)
 98			if err != nil {
 99				return r.sendEmptyOrError(m, err)
100			}
101			toPath, err := r.readString(m, "MoveFile toPath", toPathPtr, toPathSize)
102			if err != nil {
103				return r.sendEmptyOrError(m, err)
104			}
105			return r.sendEmptyOrError(m, r.moveFile(fromFs, fromPath, toFs, toPath))
106		}
107	} else {
108		return func(_ context.Context, m api.Module, fromFsPtr, fromPathPtr, toFsPtr, toPathPtr uint32) uint64 {
109			fromFs, err := r.readASString(m, "MoveFileAS fromFs", fromFsPtr)
110			if err != nil {
111				return r.sendEmptyOrError(m, err)
112			}
113			fromPath, err := r.readASString(m, "MoveFileAS fromGlobPath", fromPathPtr)
114			if err != nil {
115				return r.sendEmptyOrError(m, err)
116			}
117			toFs, err := r.readASString(m, "MoveFileAS toFs", toFsPtr)
118			if err != nil {
119				return r.sendEmptyOrError(m, err)
120			}
121			toPath, err := r.readASString(m, "MoveFileAS toPath", toPathPtr)
122			if err != nil {
123				return r.sendEmptyOrError(m, err)
124			}
125			return r.sendEmptyOrError(m, r.moveFile(fromFs, fromPath, toFs, toPath))
126		}
127	}
128}
129```
130
131When I need to pass complex objects, I use `json.Marshal` in GitRoot and `json.Unmarshal` in the SDK. It works, but don't get too attached to it, I might change the serialization method in the future.
132
133## Inside the SDK
134
135Now that the host is ready, we need to implement the other side: the SDK itself. I usually start with Go, then move to Rust and AssemblyScript. Sometimes I forget the others... shame on me! I'm planning to automate this with some boilerplate generation later.
136
137In the [server](../app/libs/golang/plugin/model/server.go) interface:
138
139```go
140type Server interface {
141    //...
142    MoveFile(fromFs, fromPath, toFs, toPath string) error
143    //...
144}
145```
146
147To make it more "user-friendly", I use a struct to represent the filesystem in [model/fs.go](../app/libs/golang/plugin/model/fs.go):
148
149```go
150func (g *GrFs) MoveFile(fromPath string, toPath string) error {
151    return g.server.MoveFile(g.base, fromPath, g.base, toPath)
152}
153
154func (g *GrFs) MoveFileToFs(fromPath string, toFs *GrFs, toPath string) error {
155    return g.server.MoveFile(g.base, fromPath, toFs.base, toPath)
156}
157```
158
159The actual WASM call happens in [plugin/server.go](../app/libs/golang/plugin/server.go), where we transform strings back into pointers:
160
161```go
162// MoveFile implements [model.ServerNeeded].
163func (s *pServer) MoveFile(fromFs model.FsBase, fromPath string, toFs model.FsBase, toPath string) error {
164	ptr, size := stringToPtr(string(fromFs))
165	ptr2, size2 := stringToPtr(fromPath)
166	ptr3, size3 := stringToPtr(string(toFs))
167	ptr4, size4 := stringToPtr(toPath)
168	resPtrSize := _moveFile(ptr, size, ptr2, size2, ptr3, size3, ptr4, size4)
169	runtime.KeepAlive(fromFs)
170	runtime.KeepAlive(fromPath)
171	runtime.KeepAlive(toFs)
172	runtime.KeepAlive(toPath)
173	return ptrSizeToError(resPtrSize)
174}
175```
176
177And finally, the link to the host via `go:wasmimport` in [plugin/imports.go](../app/libs/golang/plugin/imports.go):
178
179```go
180//go:wasmimport gitroot moveFile
181func _moveFile(fromFsPtr, fromFsSize, fromPathPtr, fromPathSize, toFsPtr, toFsSize, toPathPtr, toPathSize uint32) (ptrSize uint64)
182```
183
184## How to use it?
185
186Now, any plugin can use this. Here's a simple Go plugin that triggers a build whenever a `.go` file is modified and moves the binary to the web folder.
187
188```go
189package main
190
191import (
192	"fmt"
193	"path/filepath"
194	"strings"
195
196	gitroot "gitroot.dev/libs/golang/plugin"
197	"gitroot.dev/libs/golang/plugin/model"
198)
199
200var defaultRun = []model.PluginRun{{
201	Path:   "**/*.go",
202	When:   model.PluginRunWhenAll,
203	Branch: []string{"main"},
204	Write: model.PluginWrite{
205		Web: []model.PluginWriteRight{{
206			Path: "releases/main",
207			Can:  model.PluginWriteRightCanAll,
208		}},
209	},
210}}
211
212type Plugin struct {
213	server model.Server
214    needToBuild bool
215}
216
217func (p *Plugin) Init(repoName string, confHasChanged bool, serializedConf string) error {
218    p.needToBuild = false
219    return nil
220}
221
222func (p *Plugin) StartCommit(commit model.Commit) error                                  { return nil }
223
224func (p *Plugin) AddFile(file model.File) error                                          {
225	p.needToBuild = true
226    return nil
227}
228
229func (p *Plugin) ModFile(file model.File) error                                          {
230	p.needToBuild = true
231    return nil
232}
233
234func (p *Plugin) DelFile(file model.File) error                                          {
235	p.needToBuild = true
236    return nil
237}
238
239func (p *Plugin) EndCommit(commit model.Commit) error                                    { return nil }
240
241func (p *Plugin) Finish() error                                                          {
242    if p.needToBuild {
243        res, _ := p.server.Exec(model.Exec{
244            Cmds: []model.Cmd{
245                {Cmd: "go", Args: []string{"build"}},
246            },
247            Artifacts: []string{"main"},
248        })
249        return p.server.Cache().MoveFileToFs(res.Artifacts[0], p.server.Webcontent(), filepath.Join("releases", "main"))
250	}
251    return nil
252}
253
254func Build(server model.Server) model.Plugin {
255	return &Plugin{
256		server: server,
257	}
258}
259
260//go:wasmexport install
261func main() {
262	gitroot.Register(defaultRun, Build)
263}
264```
265
266## Is that all?
267
268Nope. I still need to update the [Rust](../app/libs/rust/plugin/src/fs.rs) and [AssemblyScript](../app/libs/ts/plugin/fs.ts) SDKs and, of course, add [tests](../app/libs/golang/plugin/test/fakeserver.go). But this should give you a good idea of how the plumbing works.
269
270If anything is unclear, [let me know](../contact.md)!