Skip to content
Closed
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

box/uvbox.toml
box/wheels
box/git_source.txt

boxer/boxes
boxer/boxer
Expand Down
38 changes: 37 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@

## Features

- **Package from PyPI or Wheels** — Install your application from package indexes or choose to bundle local wheel files
- **Package from PyPI, Wheels, or Git** — Install your application from package indexes, bundle local wheel files, or fetch from a git repository
- **True Cross-Compilation** — Build binaries for Linux, macOS, and Windows (AMD64/ARM64) from any platform in seconds
- **Auto-Updates** — Built-in version checking and self-update/fallback capabilities for your binaries
- **Dependency Freezing** — Use constraints files to ensure reproducible installations
Expand Down Expand Up @@ -116,6 +116,42 @@ Package local wheel files instead of installing from PyPI:
uvbox wheel --config uvbox.toml ./my-app.whl
```

### Build from a Git Repository

Package an application from a git repository that isn't published to PyPI:

```bash
# Default branch
uvbox git git+https://github.com/org/repo --config uvbox.toml

# Specific tag
uvbox git git+https://github.com/org/repo@v1.0.0

# Specific branch
uvbox git git+https://github.com/org/repo@main

# Specific commit
uvbox git git+https://github.com/org/repo@abc123

# SSH (uses your local git credentials)
uvbox git git+ssh://git@github.com/org/private-repo
```

The git spec is passed through to `uv tool install --from` verbatim at runtime
on the end-user's machine. `uvbox` itself never clones the repository at build
time — the clone happens on first run of the generated binary. This means you
can build binaries for a private repo without providing credentials to the
build machine; the end user's local git/ssh setup handles authentication.

**Behavior of `[package.version]` for git builds:**
- `static` and `dynamic` are ignored for install resolution — the git ref in
the spec is the source of truth.
- `auto-update = true` re-runs `uv tool install --from <spec> --upgrade` on
every invocation, giving you the "fresh dependencies every run" behavior
equivalent to `pycrucible`'s `delete_after_run = true`.
- Leave `dynamic` unset when tracking a moving branch; setting it will disable
the always-update behavior.

## Configuration

### Using pyproject.toml
Expand Down
3 changes: 3 additions & 0 deletions box/box_package.go
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,9 @@ func (b *Box) InstalledPackagePath() (string, error) {
}

func (b *Box) uvToolInstall(packageVersion, constraintsFile string) error {
if GIT_SOURCE != "" {
return b.uvToolInstallGit(constraintsFile)
}
if INSTALL_WHEELS == "no" {
return b.uvToolInstallPypi(packageVersion, constraintsFile)
} else {
Expand Down
89 changes: 89 additions & 0 deletions box/box_package_git.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package main

import (
_ "embed"
"fmt"
"os"
"os/exec"
"strings"
)

// gitSourceContent is the raw content of git_source.txt, embedded at build
// time. For `uvbox git <spec>` builds, boxer writes the git spec into this
// file before invoking `go build`. For pypi/wheel builds, the file exists
// but is empty — matching the committed placeholder in box/git_source.txt.
//
// We use a file-embed instead of an ldflag (-X main.GIT_SOURCE=...) because
// Go's GOFLAGS parser does not support spaces inside flag values, and the
// boxer build path routes ldflags through GOFLAGS for Windows compatibility
// (see issue #7). Passing `-X main.FOO=bar` via GOFLAGS fails to parse.
//
//go:embed git_source.txt
var gitSourceContent string

// GIT_SOURCE is the embedded git source string (e.g., "git+https://github.com/org/repo@main")
// for binaries produced by `uvbox git <spec>`. It is empty for pypi/wheel
// builds, in which case the runtime install path skips the git dispatch
// entirely. Trimmed on init to tolerate trailing whitespace/newlines in
// the embedded file.
var GIT_SOURCE = strings.TrimSpace(gitSourceContent)

// buildUvToolInstallFromArgs constructs the command-line arguments for
// `uv tool install --from <gitSource> <packageName> --upgrade`, optionally
// appending `--with-requirements <constraintsFile>`. Pure function: no
// side effects, easy to unit-test.
//
// The `--upgrade` flag is always included for git sources because there is
// no "pinned version" concept — every install/update must re-resolve the ref.
func buildUvToolInstallFromArgs(uvPath, gitSource, packageName, constraintsFile string) []string {
args := []string{
uvPath,
"--quiet",
"tool",
"install",
"--from",
gitSource,
packageName,
"--upgrade",
}
if constraintsFile != "" {
args = append(args, "--with-requirements", constraintsFile)
}
return args
}

// uvToolInstallGit installs the package from the embedded GIT_SOURCE via
// `uv tool install --from <GIT_SOURCE> <PackageName> --upgrade`. Mirrors
// uvToolInstallPypi and uvToolInstallWheels in shape and error handling,
// but uses --from to delegate git-spec parsing to uv itself.
func (b *Box) uvToolInstallGit(constraintsFile string) error {
logger.Debug("Installing package from git",
logger.Args("name", b.PackageName, "source", GIT_SOURCE, "constraintsFile", constraintsFile))

uv, err := b.InstalledUvExecutablePath()
if err != nil {
return fmt.Errorf("could not find uv executable: %w", err)
}

commandArgs := buildUvToolInstallFromArgs(uv, GIT_SOURCE, b.PackageName, constraintsFile)

env, err := b.commandsEnvironment()
if err != nil {
return fmt.Errorf("could not get uv environment variables: %w", err)
}

cmd := exec.Command(commandArgs[0], commandArgs[1:]...)
cmd.Env = env
cmd.Stderr = os.Stderr
if debugEnabled() || traceEnabled() {
cmd.Stdout = os.Stdout
}
logger.Trace("Running", logger.Args("command", commandArgs, "env", env))

if err := cmd.Run(); err != nil {
return fmt.Errorf("failed to run command %v: %w", commandArgs, err)
}

logger.Debug("Installed", logger.Args("package", b.PackageName))
return nil
}
59 changes: 59 additions & 0 deletions box/box_package_git_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package main

import (
"reflect"
"testing"
)

func TestBuildUvToolInstallFromArgs_Minimal(t *testing.T) {
got := buildUvToolInstallFromArgs("/path/to/uv", "git+https://github.com/org/repo", "mypkg", "")
want := []string{
"/path/to/uv",
"--quiet",
"tool",
"install",
"--from",
"git+https://github.com/org/repo",
"mypkg",
"--upgrade",
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("buildUvToolInstallFromArgs minimal = %v, want %v", got, want)
}
}

func TestBuildUvToolInstallFromArgs_WithRef(t *testing.T) {
got := buildUvToolInstallFromArgs("uv", "git+https://github.com/org/repo@v1.0.0", "mypkg", "")
want := []string{
"uv",
"--quiet",
"tool",
"install",
"--from",
"git+https://github.com/org/repo@v1.0.0",
"mypkg",
"--upgrade",
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("buildUvToolInstallFromArgs with ref = %v, want %v", got, want)
}
}

func TestBuildUvToolInstallFromArgs_WithConstraints(t *testing.T) {
got := buildUvToolInstallFromArgs("uv", "git+https://github.com/org/repo", "mypkg", "/tmp/constraints.txt")
want := []string{
"uv",
"--quiet",
"tool",
"install",
"--from",
"git+https://github.com/org/repo",
"mypkg",
"--upgrade",
"--with-requirements",
"/tmp/constraints.txt",
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("buildUvToolInstallFromArgs with constraints = %v, want %v", got, want)
}
}
3 changes: 3 additions & 0 deletions box/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,9 @@ func (c Configuration) PanicIfInvalid() {
func (c Configuration) ComputeIdentifier() string {
hasher := crypto.SHA1.New()
textToHash := fmt.Sprintf("%s%s%s%t", c.Package.Name, c.Package.Script, c.Package.Version.Static, c.AutoUpdateEnabled())
if GIT_SOURCE != "" {
textToHash += GIT_SOURCE
}
_, err := io.WriteString(hasher, textToHash)
if err != nil {
logger.Fatal("Failed to hash script value", logger.Args("error", err))
Expand Down
71 changes: 71 additions & 0 deletions box/config_identifier_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package main

import (
"testing"
)

// makeTestConfig returns a Configuration representative of a typical pypi build.
func makeTestConfig() Configuration {
return Configuration{
Package: PackageConfiguration{
Name: "my-package",
Script: "my-script",
Version: PackageVersionConfiguration{
AutoUpdate: true,
Static: "1.0.0",
},
},
}
}

// TestComputeIdentifier_StableForPypi is the regression guard for
// "additive only, don't affect existing features". The expected value
// was captured from the pre-git-support version of ComputeIdentifier
// and must not change when GIT_SOURCE is empty.
func TestComputeIdentifier_StableForPypi(t *testing.T) {
// Ensure GIT_SOURCE is empty for this test (it's the default, but be explicit).
origGitSource := GIT_SOURCE
GIT_SOURCE = ""
t.Cleanup(func() { GIT_SOURCE = origGitSource })

cfg := makeTestConfig()
got := cfg.ComputeIdentifier()
want := "my-package-30bb8d607d1417531f6df2a733f8ad02439c2b3a"
if got != want {
t.Fatalf("ComputeIdentifier() = %q, want %q (regression: existing pypi/wheel binaries must hash identically)", got, want)
}
}

func TestComputeIdentifier_DiffersByGitSource(t *testing.T) {
cfg := makeTestConfig()

origGitSource := GIT_SOURCE
t.Cleanup(func() { GIT_SOURCE = origGitSource })

GIT_SOURCE = ""
pypiID := cfg.ComputeIdentifier()

GIT_SOURCE = "git+https://github.com/org/repo"
gitID := cfg.ComputeIdentifier()

if pypiID == gitID {
t.Fatalf("expected git build to produce a different identifier than pypi build, both got %q", pypiID)
}
}

func TestComputeIdentifier_DiffersByGitRef(t *testing.T) {
cfg := makeTestConfig()

origGitSource := GIT_SOURCE
t.Cleanup(func() { GIT_SOURCE = origGitSource })

GIT_SOURCE = "git+https://github.com/org/repo@main"
mainID := cfg.ComputeIdentifier()

GIT_SOURCE = "git+https://github.com/org/repo@v1.0.0"
tagID := cfg.ComputeIdentifier()

if mainID == tagID {
t.Fatalf("expected different git refs to produce different identifiers, both got %q", mainID)
}
}
5 changes: 5 additions & 0 deletions box/generate.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ var CONFIGURATION_FILENAME = "uvbox.toml"
var CERTIFICATES_BUNDLE_FILENAME = "ca-bundle.crt"
var WHEELS_FOLDER = "wheels"
var WHEELS_PLACEHOLDER = filepath.Join(WHEELS_FOLDER, "placeholder")
var GIT_SOURCE_FILENAME = "git_source.txt"

func deleteIfExists(filename string) {
if _, err := os.Stat(filename); err != nil && os.IsNotExist(err) {
Expand Down Expand Up @@ -50,4 +51,8 @@ func main() {

// Generate wheels folder placeholder
generateEmptyFileIfMissing(WHEELS_PLACEHOLDER)

// Generate empty git_source.txt placeholder (populated at uvbox-build
// time by boxer's writeGitSourceFile when `uvbox git <spec>` is used).
generateEmptyFileIfMissing(GIT_SOURCE_FILENAME)
}
62 changes: 62 additions & 0 deletions boxer/git.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package main

import (
"fmt"
"os"
"path/filepath"
"strings"
)

// buildGoBuildLdflags constructs the ldflags string passed to `go build`
// via the `GOFLAGS=-ldflags=...` environment variable. Pure function:
// no side effects, no package-level state reads, so it can be unit-tested
// without invoking go build.
//
// Behavior:
// - Always includes "-s -w" to strip debug info.
// - If wheels are embedded, adds "-X main.INSTALL_WHEELS=yes" (unchanged
// from the pre-git-support behavior — regression test locks this in).
//
// Note on git source: unlike wheels, the git source is NOT injected via
// ldflags. Go's GOFLAGS parser (strings.Fields) does not support spaces
// inside flag values, so `-X main.GIT_SOURCE=<spec>` breaks parsing for
// any ldflag string containing `-X`. Instead, the git source is written
// to box/git_source.txt and embedded via //go:embed — see
// writeGitSourceFile below and box/box_package_git.go.
func buildGoBuildLdflags(wheelsToEmbed []string) string {
ldflags := "-s -w"
if len(wheelsToEmbed) > 0 {
ldflags += " -X main.INSTALL_WHEELS=yes"
}
return ldflags
}

// writeGitSourceFile writes the provided git source string to
// <boxRepository>/git_source.txt, which is embedded into the compiled
// binary via //go:embed in box/box_package_git.go. The file is always
// written (even with an empty string) to satisfy the go:embed directive,
// which requires the target file to exist at build time. An empty file
// means "not a git build"; a non-empty file means "git build, this is
// the spec to pass to uv tool install --from".
func writeGitSourceFile(boxRepository, gitSource string) error {
target := filepath.Join(boxRepository, "git_source.txt")
if err := os.WriteFile(target, []byte(gitSource), 0644); err != nil {
return fmt.Errorf("failed to write git source file to %s: %w", target, err)
}
return nil
}

// validateGitSource is called from preRun when a GitSource CLI argument
// was provided. It enforces the only format constraint we validate in
// uvbox: the spec must begin with "git+". Everything else is delegated
// to uv, which surfaces malformed specs loudly on first run of the
// generated binary.
func validateGitSource(gitSource string) error {
if gitSource == "" {
return fmt.Errorf("git source must not be empty")
}
if !strings.HasPrefix(gitSource, "git+") {
return fmt.Errorf("git source must start with 'git+' (e.g. git+https://github.com/org/repo), got %q", gitSource)
}
return nil
}
Loading