Skip to content
internal/effects
On this page

The single authorized surface for effectful calls: every subprocess launch and every filesystem mutation the engine makes goes through one effects handle.

#internal/effects

#internal/effects

Package effects is the single authorized surface for effectful calls in selfdoc production code.

Every subprocess launch and every filesystem mutation made by the engine packages goes through a [Handle]. Nothing else may call os/exec, os.WriteFile, os.Rename, os.MkdirAll, os.RemoveAll or their siblings directly.

Why a chokepoint: selfdoc rides strictcli's effects regime, where every mutation is declared, previewable under --dry-run, and recorded into the would-do log. selfdoc force-pushes a gh-pages branch, deploys to Cloudflare Pages, creates GitHub repositories, sets repository secrets and auto-commits to the user's working tree -- a --dry-run that executed any of that would be worse than no dry run at all. With every effect funnelled through this one package the regime is adapted in one file instead of at ~150 call sites.

#No ambient binding

There is no package-level handle and no contextvar equivalent. A command handler builds its handle with [FromContext] and passes it explicitly to every engine function that mutates or spawns; a library caller -- the build pipeline, the check helpers, the test suite -- builds an [Unbound] handle, which executes everything directly.

#The mode rule (declared, never inferred)

- Preview mode -- a handle built from a context whose --dry-run flag was passed. Every mutating operation is minted on the strictcli effects handle: recorded, never executed. A [Result] then reports Unsettled true, and the filesystem operations report no error while having changed nothing. - Live mode -- an unbound handle, or a bound handle outside --dry-run. The operations execute directly, with their full selfdoc semantics: per-call timeouts, byte captures, stdin payload streaming, [Handle.AtomicWrite]'s temp-file-plus-rename (the only way to rewrite the 0444 generated root files), and the missing-is-an-error distinctions call sites branch on. strictcli's closed method set expresses none of those, so routing a live run through it would silently drop a hang guard or a permission-preserving rename.

The split is by mode, decided before anything runs, and identical on every invocation -- it is not a fallback: nothing here ever tries the strictcli handle, fails, and retries elsewhere.

#Reads are never effects

[Read] marks a subprocess run as a declared read. A declared read executes in every mode and is never minted, never recorded and never logged -- the same treatment strictcli gives an allowlisted observe, and for the same reason: a preview that could not look at the world would have nothing to preview. It is declared per call site rather than through an app-level proc_observe_allowlist because the argv cannot classify these: gh api is both a GET of repository contents and a POST of a workflow dispatch, and git is both rev-parse and push --force.

#ModeDefault

Go go
const ModeDefault fs.FileMode = 0

ModeDefault is the file mode operand meaning "no explicit mode": the file is created with 0644 as modified by the process umask, and no chmod is recorded in a preview. It is the counterpart of the Python surface's permissions=None.

#ErrTimeout

Go go
var ErrTimeout = errors.New("timed out")

ErrTimeout is the sentinel every timed-out subprocess error wraps.

#ExitError

Go go
type ExitError struct

ExitError is returned by [Handle.Run] when the caller declared [Check] and the child exited non-zero. It is the counterpart of Python's subprocess.CalledProcessError.

#Result

Go go
type Result struct

Result is the outcome of [Handle.Run] or [Handle.Pipeline].

#Handle

Go go
type Handle struct

Handle is the effects handle an engine function receives as an explicit parameter. Build one with [FromContext] inside a command handler, or with [Unbound] for a library call.

A Handle is immutable after construction and safe to share across goroutines to the extent the underlying operations are.

#Option

Go go
type Option struct

Option is one trailing option on a [Handle.Run] or [Handle.Pipeline] call. An option a method does not accept is a call-time error: silently ignoring one is the single outcome a declare-everything surface cannot have.

#FromContext

Go go
func FromContext(ctx *strictcli.Context) *Handle

FromContext builds the handle for a command dispatch.

The returned handle previews when ctx was invoked with --dry-run and executes directly otherwise. A nil ctx yields an [Unbound] handle, so a test that dispatches nothing still gets a working handle.

#Unbound

Go go
func Unbound() *Handle

Unbound builds a handle with no strictcli effects handle behind it: every operation executes directly. This is the library path -- the build pipeline, the check helpers and unit tests all call the engine outside a command dispatch, and there is nothing to mint on there.

#Cwd

Go go
func Cwd(dir string) Option

Cwd sets the working directory of the child process.

#Env

Go go
func Env(env map[string]string) Option

Env sets the complete environment of the child process, replacing the inherited one -- the semantics of Python's subprocess env argument, which every selfdoc call site was written against. Omit it to inherit.

A preview renders the entries as overrides instead, because strictcli's recorded env merges over the inherited environment; nothing executes there, so the difference is confined to the rendered line.

#Timeout

Go go
func Timeout(d time.Duration) Option

Timeout kills the child and returns an error wrapping [ErrTimeout] when it has not exited within d. Zero means no deadline.

#Check

Go go
func Check() Option

Check makes a non-zero exit an [ExitError] instead of a [Result]. Off by default, matching the Python surface.

#CaptureOutput

Go go
func CaptureOutput() Option

CaptureOutput captures the child's stdout and stderr into the [Result] instead of letting it inherit this process's streams.

#Stdin

Go go
func Stdin(payload []byte) Option

Stdin writes payload to the child's standard input and closes it.

#Read

Go go
func Read() Option

Read declares this run an observation: it changes nothing, so it executes in every mode and is never recorded.

#Resource

Go go
func Resource(token string) Option

Resource declares an opaque token naming what this run produces. Preview metadata only.

#SkipIfCurrent

Go go
func SkipIfCurrent(token string) Option

SkipIfCurrent declares the token a preview annotates the recorded line with, spelling out that the handler skips this step when the resource is current.

#Grant

Go go
func Grant(name string) Option

Grant names a grant declared on the running command, whose reason is rendered beside the recorded step in the preview.

#Stream

Go go
func Stream(stream bool) Option

Stream overrides whether a recorded run is rendered as inheriting this process's streams. Without it a recorded run streams unless the caller declared [CaptureOutput].

#ShellQuote

Go go
func ShellQuote(token string) string

ShellQuote quotes token for a rendered /bin/sh -c line, leaving a token made only of characters no shell reinterprets unquoted.

#ExitError.Error

Go go
func (e *ExitError) Error() string

Error renders the failed command and its exit status.

#Result.StdoutString

Go go
func (r Result) StdoutString() string

StdoutString is Stdout decoded as text with one trailing newline removed -- the form call sites forward into a later command's argv.

#Result.StderrString

Go go
func (r Result) StderrString() string

StderrString is Stderr decoded as text with one trailing newline removed.

#Handle.Previewing

Go go
func (h *Handle) Previewing() bool

Previewing reports whether this handle records mutations instead of performing them.

#Handle.Run

Go go
func (h *Handle) Run(argv []string, options ...Option) (Result, error)

Run runs a command to completion and returns its [Result].

In preview mode a declared read ([Read]) still executes and returns a real result; anything else is recorded and returns a result whose Unsettled field is true, standing in for the run that did not happen.

#Handle.Pipeline

Go go
func (h *Handle) Pipeline(argvs [][]string, options ...Option) (Result, error)

Pipeline runs the stages of argvs as one shell pipeline -- stage N's stdout feeds stage N+1's stdin -- and returns the last stage's result.

strictcli's closed method set has no pipeline, so a preview records the whole chain as the one /bin/sh -c invocation that performs it: a faithful rendering of the work, not an invented one. Live mode keeps the real multi-process pipeline, which is what streams a git archive into tar without buffering the whole tree in memory.

Only the last stage's stderr is captured; the earlier stages inherit this process's stderr.

#Handle.Write

Go go
func (h *Handle) Write(path string, content []byte, mode fs.FileMode) error

Write writes content to path, truncating any existing file.

Pass [ModeDefault] as mode to leave the mode to the process umask; any other value is applied to the file, and recorded as a chmod in a preview.

#Handle.AtomicWrite

Go go
func (h *Handle) AtomicWrite(path string, content []byte, mode fs.FileMode) error

AtomicWrite writes content to path atomically: the bytes land in a sibling temporary file that is renamed over the target in one directory operation.

A crash mid-write can therefore never leave a truncated file. Because the rename is a directory operation it also succeeds when path itself is read-only (the 0444 generated root files) with no unlock step -- which is why live mode keeps the temp-file dance instead of routing through a plain write.

Pass [ModeDefault] as mode to leave the mode alone.

#Handle.OpenWrite

Go go
func (h *Handle) OpenWrite(path string) (io.WriteCloser, error)

OpenWrite opens path for writing, truncating any existing file, and returns the stream. Use it for writers that produce their bytes incrementally; whole-content writers should prefer [Handle.Write] or [Handle.AtomicWrite].

strictcli's closed method set has no streaming write, so in preview mode the content accumulates in memory and Close mints the single resulting write, carrying the byte count the file would have had. Close must therefore be called -- and its error checked -- on every path.

#Handle.OpenAppend

Go go
func (h *Handle) OpenAppend(path string) (io.WriteCloser, error)

OpenAppend opens path for appending, creating it when missing, and returns the stream.

strictcli's closed method set has no append, so in preview mode the recorded write carries the whole resulting file -- the existing bytes plus the appended ones -- and the preview's byte count is the real one.

#recordedWriter.Write

Go go
func (w *recordedWriter) Write(p []byte) (int, error)

Write accumulates p in memory.

#recordedWriter.Close

Go go
func (w *recordedWriter) Close() error

Close mints the single write standing for everything that was streamed.

#Handle.Mkdir

Go go
func (h *Handle) Mkdir(path string) error

Mkdir creates path and any missing parents. An already-existing path is an error, matching Python's os.makedirs default.

#Handle.MkdirAll

Go go
func (h *Handle) MkdirAll(path string) error

MkdirAll creates path and any missing parents. An already-existing path is not an error.

#Handle.Remove

Go go
func (h *Handle) Remove(path string) error

Remove deletes the file or symlink at path. A missing path is an error.

#Handle.RemoveIfExists

Go go
func (h *Handle) RemoveIfExists(path string) error

RemoveIfExists deletes the file or symlink at path. A missing path is not an error.

#Handle.Rmdir

Go go
func (h *Handle) Rmdir(path string) error

Rmdir removes the empty directory at path.

#Handle.RmTree

Go go
func (h *Handle) RmTree(path string) error

RmTree recursively deletes the directory tree at path. A missing path is an error, matching Python's shutil.rmtree default.

#Handle.RmTreeIgnoreErrors

Go go
func (h *Handle) RmTreeIgnoreErrors(path string) error

RmTreeIgnoreErrors recursively deletes the directory tree at path, ignoring filesystem errors -- including a missing path. The returned error is only ever a preview-mode recording failure.

#Handle.Chmod

Go go
func (h *Handle) Chmod(path string, mode fs.FileMode) error

Chmod sets the permission bits of path.

#Handle.Rename

Go go
func (h *Handle) Rename(src, dst string) error

Rename moves src to dst.

#Handle.CopyFile

Go go
func (h *Handle) CopyFile(src, dst string) error

CopyFile copies src to dst, preserving src's permission bits.

#Handle.CopyTree

Go go
func (h *Handle) CopyTree(src, dst string, dirsExistOK bool) error

CopyTree recursively copies the directory tree src to dst. With dirsExistOK false an already-existing dst is an error.

A preview records one mkdir plus one write per file, so it names every path the copy would create rather than one opaque "copy tree" line.

Search