Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 33 additions & 36 deletions internal/forge/backend/backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,6 @@ type Backend interface {
// ExecWithContext runs cmd respecting ctx cancellation, writing to w when non-nil.
// env contains additional environment variables merged on top of the parent process env.
ExecWithContext(ctx context.Context, dir string, cmd []string, env map[string]string, w io.Writer) error
// BinaryExists checks whether the named binary is available in this backend.
BinaryExists(dir, binary string) bool
}

// HostBackend runs commands directly on the host.
Expand Down Expand Up @@ -60,20 +58,6 @@ func (b *HostBackend) ExecWithContext(ctx context.Context, dir string, cmd []str
return c.Run()
}

func (b *HostBackend) BinaryExists(dir, binary string) bool {
local := []string{
filepath.Join(dir, "vendor", "bin", binary),
filepath.Join(dir, "node_modules", ".bin", binary),
}
for _, p := range local {
if _, err := os.Stat(p); err == nil {
return true
}
}
_, err := exec.LookPath(binary)
return err == nil
}

// DdevBackend routes commands through `docker exec` into the DDEV web container.
// This is faster than `ddev exec` because it skips the DDEV CLI overhead.
type DdevBackend struct{}
Expand All @@ -97,8 +81,8 @@ func (b *DdevBackend) ExecWithContext(ctx context.Context, dir string, cmd []str
if err != nil {
return fmt.Errorf("ddev backend: %w", err)
}
// docker exec -i -w /var/www/html [-e KEY=VAL ...] <container> <cmd...>
dockerArgs := []string{"docker", "exec", "-i", "-w", "/var/www/html"}
containerDir := ddevContainerDir(dir)
dockerArgs := []string{"docker", "exec", "-i", "-w", containerDir}
for k, v := range env {
dockerArgs = append(dockerArgs, "-e", k+"="+v)
}
Expand All @@ -117,20 +101,6 @@ func (b *DdevBackend) ExecWithContext(ctx context.Context, dir string, cmd []str
return c.Run()
}

func (b *DdevBackend) BinaryExists(dir, binary string) bool {
local := []string{
filepath.Join(dir, "vendor", "bin", binary),
filepath.Join(dir, "node_modules", ".bin", binary),
}
for _, p := range local {
if _, err := os.Stat(p); err == nil {
return true
}
}
system := map[string]bool{"php": true, "composer": true, "node": true, "npm": true}
return system[binary]
}

func (b *DockerBackend) Name() string { return b.container }

func (b *DockerBackend) Exec(dir string, cmd []string) error {
Expand Down Expand Up @@ -161,10 +131,6 @@ func (b *DockerBackend) ExecWithContext(ctx context.Context, dir string, cmd []s
return c.Run()
}

func (b *DockerBackend) BinaryExists(dir, binary string) bool {
return true
}

// ResolveBackend returns the appropriate backend for a tool in the given repo root.
// Priority: per-tool override → global config default → DDEV auto-detect → host
func ResolveBackend(repoRoot string, tool config.ToolConfig, globalDefault string) Backend {
Expand Down Expand Up @@ -234,6 +200,37 @@ func ddevContainerName(repoRoot string) (string, error) {
return "ddev-" + name + "-web", nil
}

// ddevProjectRoot walks up from startDir until it finds a directory containing
// a .ddev/ subdirectory, returning the DDEV project root.
func ddevProjectRoot(startDir string) (string, error) {
dir := filepath.Clean(startDir)
for {
if _, err := os.Stat(filepath.Join(dir, ".ddev")); err == nil {
return dir, nil
}
parent := filepath.Dir(dir)
if parent == dir {
return "", fmt.Errorf("no .ddev directory found above %s", startDir)
}
dir = parent
}
}

// ddevContainerDir translates a host absolute path to the equivalent path
// inside the DDEV web container, which mounts the project root at /var/www/html.
// If the project root cannot be determined, /var/www/html is returned.
func ddevContainerDir(hostDir string) string {
root, err := ddevProjectRoot(hostDir)
if err != nil {
return "/var/www/html"
}
rel, err := filepath.Rel(root, hostDir)
if err != nil || rel == "." {
return "/var/www/html"
}
return "/var/www/html/" + filepath.ToSlash(rel)
}

// ResolveCommandForBackend resolves a vendor/node_modules binary path relative
// to repo root depending on the tool type and active backend.
func ResolveCommandForBackend(repoRoot string, tool config.ToolConfig, backend Backend) string {
Expand Down
132 changes: 64 additions & 68 deletions internal/forge/backend/backend_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -137,45 +137,6 @@ func TestHostBackend_ExecWithContext_Cancelled(t *testing.T) {
}
}

func TestHostBackend_BinaryExists_SystemBinary(t *testing.T) {
b := &HostBackend{}
// "echo" should be on PATH on any Unix host
if !b.BinaryExists(t.TempDir(), "echo") {
t.Error("expected 'echo' to be found via LookPath")
}
}

func TestHostBackend_BinaryExists_LocalVendorBin(t *testing.T) {
dir := t.TempDir()
vendorBin := filepath.Join(dir, "vendor", "bin")
_ = os.MkdirAll(vendorBin, 0o755)
_ = os.WriteFile(filepath.Join(vendorBin, "phpstan"), []byte("#!/bin/sh"), 0o755)

b := &HostBackend{}
if !b.BinaryExists(dir, "phpstan") {
t.Error("expected local vendor/bin/phpstan to be found")
}
}

func TestHostBackend_BinaryExists_LocalNodeBin(t *testing.T) {
dir := t.TempDir()
nodeBin := filepath.Join(dir, "node_modules", ".bin")
_ = os.MkdirAll(nodeBin, 0o755)
_ = os.WriteFile(filepath.Join(nodeBin, "eslint"), []byte("#!/bin/sh"), 0o755)

b := &HostBackend{}
if !b.BinaryExists(dir, "eslint") {
t.Error("expected local node_modules/.bin/eslint to be found")
}
}

func TestHostBackend_BinaryExists_Missing(t *testing.T) {
b := &HostBackend{}
if b.BinaryExists(t.TempDir(), "this-binary-does-not-exist-forge-test") {
t.Error("expected missing binary to return false")
}
}

// ---------- DdevBackend ----------

func TestDdevBackend_Name(t *testing.T) {
Expand All @@ -196,35 +157,6 @@ func TestDdevBackend_ExecWithContext_NoDdevConfig(t *testing.T) {
}
}

func TestDdevBackend_BinaryExists_LocalVendorBin(t *testing.T) {
dir := t.TempDir()
vendorBin := filepath.Join(dir, "vendor", "bin")
_ = os.MkdirAll(vendorBin, 0o755)
_ = os.WriteFile(filepath.Join(vendorBin, "phpstan"), []byte("#!/bin/sh"), 0o755)

b := &DdevBackend{}
if !b.BinaryExists(dir, "phpstan") {
t.Error("expected local vendor/bin/phpstan to be found")
}
}

func TestDdevBackend_BinaryExists_KnownSystemBinary(t *testing.T) {
b := &DdevBackend{}
for _, bin := range []string{"php", "composer", "node", "npm"} {
if !b.BinaryExists(t.TempDir(), bin) {
t.Errorf("expected known system binary %q to return true", bin)
}
}
}

func TestDdevBackend_BinaryExists_UnknownBinary(t *testing.T) {
b := &DdevBackend{}
// unknown binary not in vendor/bin and not in the known list
if b.BinaryExists(t.TempDir(), "this-binary-does-not-exist-forge-test") {
t.Error("expected unknown binary to return false")
}
}

// ---------- ResolveBackend ----------

func TestResolveBackend_ExplicitHost(t *testing.T) {
Expand Down Expand Up @@ -370,3 +302,67 @@ func TestBackendAvailabilityError_Error(t *testing.T) {
t.Errorf("unexpected error message: %q", msg)
}
}

// ---------- ddevProjectRoot ----------

func TestDdevProjectRoot_FindsDirectParent(t *testing.T) {
dir := makeDdevConfig(t, "my-project")
got, err := ddevProjectRoot(dir)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != dir {
t.Errorf("got %q, want %q", got, dir)
}
}

func TestDdevProjectRoot_FindsAncestor(t *testing.T) {
root := makeDdevConfig(t, "ancestor-project")
sub := filepath.Join(root, "packages", "foo")
_ = os.MkdirAll(sub, 0o755)

got, err := ddevProjectRoot(sub)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != root {
t.Errorf("got %q, want %q", got, root)
}
}

func TestDdevProjectRoot_ErrorWhenNoDdev(t *testing.T) {
dir := t.TempDir()
_, err := ddevProjectRoot(dir)
if err == nil {
t.Error("expected error when no .ddev directory exists")
}
}

// ---------- ddevContainerDir ----------

func TestDdevContainerDir_ProjectRoot(t *testing.T) {
dir := makeDdevConfig(t, "my-project")
got := ddevContainerDir(dir)
if got != "/var/www/html" {
t.Errorf("project root should map to /var/www/html, got %q", got)
}
}

func TestDdevContainerDir_SubDirectory(t *testing.T) {
root := makeDdevConfig(t, "my-project")
sub := filepath.Join(root, "packages", "foo")
_ = os.MkdirAll(sub, 0o755)

got := ddevContainerDir(sub)
if got != "/var/www/html/packages/foo" {
t.Errorf("got %q, want /var/www/html/packages/foo", got)
}
}

func TestDdevContainerDir_FallbackWhenNoDdev(t *testing.T) {
dir := t.TempDir()
got := ddevContainerDir(dir)
if got != "/var/www/html" {
t.Errorf("expected fallback /var/www/html, got %q", got)
}
}
7 changes: 7 additions & 0 deletions internal/forge/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,13 @@ type CommitMessagePolicy struct {
PrependTicket bool `toml:"prepend_ticket"`
SkipOnMerge bool `toml:"skip_on_merge"`
SkipIfPresent bool `toml:"skip_if_present"`
// TicketPattern is a regexp matched against the branch name to extract a
// ticket ID. Defaults to [A-Z]+-[0-9]+ (Jira/Linear style) when empty.
TicketPattern string `toml:"ticket_pattern"`
// AllowedTypes restricts Conventional Commits to a custom type list.
// Defaults to: feat, fix, docs, style, refactor, perf, test, build, ci,
// chore, revert.
AllowedTypes []string `toml:"allowed_types"`
}

type ToolConfig struct {
Expand Down
13 changes: 7 additions & 6 deletions internal/forge/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -108,13 +108,14 @@ FORGE_PUSH_REMOTE="$1" FORGE_PUSH_URL="$2" \`
return fmt.Sprintf(`#!/usr/bin/env sh
set -eu

# TODO: Handle this more robustly - via forge.toml config or env var - instead of hardcoding the path to the mise shims. This is brittle and only works if mise is installed in the default location.

# Load mise shims if they exist, so that if forge is installed via mise, the shims will correctly forward to the mise-installed version.

if [ -d "$HOME/.local/share/mise/shims" ]; then
export PATH="$HOME/.local/share/mise/shims:$PATH"
# Load mise shims if available so that mise-installed tools (including forge
# itself) are on PATH. Respects MISE_DATA_DIR and XDG_DATA_HOME, matching
# mise's own path resolution order.
_mise_data="${MISE_DATA_DIR:-${XDG_DATA_HOME:-$HOME/.local/share}/mise}"
if [ -d "$_mise_data/shims" ]; then
export PATH="$_mise_data/shims:$PATH"
fi
unset _mise_data


# Prefer system-installed binary; fall back to repo-local binary (dev workflow)
Expand Down
26 changes: 26 additions & 0 deletions internal/forge/install_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,3 +127,29 @@ func TestInstall_PrePushShimHasEnvInjection(t *testing.T) {
t.Errorf("pre-push shim missing FORGE_PUSH_URL injection:\n%s", shim)
}
}

func TestBuildHookScript_MisePathRespectsMiseDataDir(t *testing.T) {
script := buildHookScript("forge", "pre-commit")
if !strings.Contains(script, "MISE_DATA_DIR") {
t.Errorf("shim should use MISE_DATA_DIR, got:\n%s", script)
}
// Must not fall back to a hardcoded path that ignores MISE_DATA_DIR
if strings.Contains(script, `$HOME/.local/share/mise/shims"`) {
t.Errorf("shim should not hardcode the mise shims path; use MISE_DATA_DIR instead")
}
}

func TestBuildHookScript_MisePathRespectsXDGDataHome(t *testing.T) {
script := buildHookScript("forge", "pre-commit")
if !strings.Contains(script, "XDG_DATA_HOME") {
t.Errorf("shim should respect XDG_DATA_HOME, got:\n%s", script)
}
}

func TestBuildHookScript_MiseFallsBackToDefault(t *testing.T) {
// The default fallback must still be $HOME/.local/share.
script := buildHookScript("forge", "pre-commit")
if !strings.Contains(script, `$HOME/.local/share`) {
t.Errorf("shim should retain $HOME/.local/share as final fallback, got:\n%s", script)
}
}
Loading
Loading