From e9108a417f08975d028c82ec2910f9759e485036 Mon Sep 17 00:00:00 2001 From: hasansezertasan Date: Fri, 10 Apr 2026 15:45:06 +0300 Subject: [PATCH 01/17] test: add regression guard for ComputeIdentifier pypi/wheel stability Co-Authored-By: Claude Sonnet 4.6 --- box/config_identifier_test.go | 71 +++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 box/config_identifier_test.go diff --git a/box/config_identifier_test.go b/box/config_identifier_test.go new file mode 100644 index 0000000..4d76d4f --- /dev/null +++ b/box/config_identifier_test.go @@ -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) + } +} From 257ba5062e89b55df3c226329e18f9bedb2cde8e Mon Sep 17 00:00:00 2001 From: hasansezertasan Date: Fri, 10 Apr 2026 16:16:16 +0300 Subject: [PATCH 02/17] feat(box): add GIT_SOURCE var and include it in identifier hash when set Co-Authored-By: Claude Sonnet 4.6 --- box/box_package_git.go | 7 +++++++ box/config.go | 3 +++ 2 files changed, 10 insertions(+) create mode 100644 box/box_package_git.go diff --git a/box/box_package_git.go b/box/box_package_git.go new file mode 100644 index 0000000..346058d --- /dev/null +++ b/box/box_package_git.go @@ -0,0 +1,7 @@ +package main + +// GIT_SOURCE is populated via ldflags at build time (-X main.GIT_SOURCE=git+...) +// when the binary is produced by `uvbox git `. When empty, the binary was +// produced by `uvbox pypi` or `uvbox wheel` and the runtime install path skips +// the git dispatch entirely. +var GIT_SOURCE = "" diff --git a/box/config.go b/box/config.go index 371130c..2965c6e 100644 --- a/box/config.go +++ b/box/config.go @@ -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)) From b8671240ad98d2e31b13d3056a7cbd2b2c085d51 Mon Sep 17 00:00:00 2001 From: hasansezertasan Date: Fri, 10 Apr 2026 16:17:43 +0300 Subject: [PATCH 03/17] feat(box): add buildUvToolInstallFromArgs pure helper Co-Authored-By: Claude Sonnet 4.6 --- box/box_package_git.go | 24 +++++++++++++++ box/box_package_git_test.go | 59 +++++++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+) create mode 100644 box/box_package_git_test.go diff --git a/box/box_package_git.go b/box/box_package_git.go index 346058d..69d3d8d 100644 --- a/box/box_package_git.go +++ b/box/box_package_git.go @@ -5,3 +5,27 @@ package main // produced by `uvbox pypi` or `uvbox wheel` and the runtime install path skips // the git dispatch entirely. var GIT_SOURCE = "" + +// buildUvToolInstallFromArgs constructs the command-line arguments for +// `uv tool install --from --upgrade`, optionally +// appending `--with-requirements `. 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 +} diff --git a/box/box_package_git_test.go b/box/box_package_git_test.go new file mode 100644 index 0000000..49e3f13 --- /dev/null +++ b/box/box_package_git_test.go @@ -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) + } +} From f7fbbf8eeead61e493353c5bd5d4bab1ed6802be Mon Sep 17 00:00:00 2001 From: hasansezertasan Date: Fri, 10 Apr 2026 16:22:04 +0300 Subject: [PATCH 04/17] feat(box): add uvToolInstallGit runtime install path Co-Authored-By: Claude Sonnet 4.6 --- box/box_package_git.go | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/box/box_package_git.go b/box/box_package_git.go index 69d3d8d..1237879 100644 --- a/box/box_package_git.go +++ b/box/box_package_git.go @@ -1,5 +1,11 @@ package main +import ( + "fmt" + "os" + "os/exec" +) + // GIT_SOURCE is populated via ldflags at build time (-X main.GIT_SOURCE=git+...) // when the binary is produced by `uvbox git `. When empty, the binary was // produced by `uvbox pypi` or `uvbox wheel` and the runtime install path skips @@ -29,3 +35,39 @@ func buildUvToolInstallFromArgs(uvPath, gitSource, packageName, constraintsFile } return args } + +// uvToolInstallGit installs the package from the embedded GIT_SOURCE via +// `uv tool install --from --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 +} From 2821635ed4161221f323a1e301d4796897c0fb49 Mon Sep 17 00:00:00 2001 From: hasansezertasan Date: Fri, 10 Apr 2026 16:22:31 +0300 Subject: [PATCH 05/17] feat(box): dispatch to uvToolInstallGit when GIT_SOURCE is set Co-Authored-By: Claude Opus 4.6 (1M context) --- box/box_package.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/box/box_package.go b/box/box_package.go index 8b2a333..356069c 100644 --- a/box/box_package.go +++ b/box/box_package.go @@ -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 { From 46e728f8a09e1adf2fb2a66f5515519450069dbf Mon Sep 17 00:00:00 2001 From: hasansezertasan Date: Fri, 10 Apr 2026 16:24:52 +0300 Subject: [PATCH 06/17] feat(boxer): add buildGoBuildLdflags pure helper and validateGitSource Extracts ldflag construction into a pure, testable function and adds validateGitSource to enforce the git+ prefix constraint. Regression tests lock in pypi and wheel ldflag output; new tests cover git source injection behavior. Co-Authored-By: Claude Sonnet 4.6 --- boxer/git.go | 47 ++++++++++++++++++++++++++++++++++++++++++++ boxer/git_test.go | 50 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+) create mode 100644 boxer/git.go create mode 100644 boxer/git_test.go diff --git a/boxer/git.go b/boxer/git.go new file mode 100644 index 0000000..a20bd50 --- /dev/null +++ b/boxer/git.go @@ -0,0 +1,47 @@ +package main + +import ( + "fmt" + "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. The arguments mirror the inputs the caller +// has at the point of invocation (gitSource CLI arg, WheelsToEmbed list). +// +// 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). +// - If a git source is set, adds "-X main.GIT_SOURCE=". +// +// The git and wheel flags are independent: validateGitSource + CLI command +// separation ensure only one of the two is actually set in practice. This +// function does not enforce mutual exclusion; the CLI layer does. +func buildGoBuildLdflags(gitSource string, wheelsToEmbed []string) string { + ldflags := "-s -w" + if len(wheelsToEmbed) > 0 { + ldflags += " -X main.INSTALL_WHEELS=yes" + } + if gitSource != "" { + ldflags += fmt.Sprintf(" -X main.GIT_SOURCE=%s", gitSource) + } + return ldflags +} + +// 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 +} diff --git a/boxer/git_test.go b/boxer/git_test.go new file mode 100644 index 0000000..135b004 --- /dev/null +++ b/boxer/git_test.go @@ -0,0 +1,50 @@ +package main + +import ( + "strings" + "testing" +) + +// TestBuildGoBuildLdflags_Pypi is a regression guard: the ldflag string +// produced for a plain pypi build must match what goBuild used to produce +// before git support was added. +func TestBuildGoBuildLdflags_Pypi(t *testing.T) { + got := buildGoBuildLdflags("", nil) + want := "-s -w" + if got != want { + t.Fatalf("buildGoBuildLdflags pypi = %q, want %q", got, want) + } +} + +// TestBuildGoBuildLdflags_Wheel is a regression guard for the wheel path. +func TestBuildGoBuildLdflags_Wheel(t *testing.T) { + got := buildGoBuildLdflags("", []string{"some-wheel.whl"}) + want := "-s -w -X main.INSTALL_WHEELS=yes" + if got != want { + t.Fatalf("buildGoBuildLdflags wheel = %q, want %q", got, want) + } +} + +func TestBuildGoBuildLdflags_Git(t *testing.T) { + got := buildGoBuildLdflags("git+https://github.com/org/repo@main", nil) + if !strings.Contains(got, "-s -w") { + t.Errorf("expected base flags -s -w, got %q", got) + } + if !strings.Contains(got, "-X main.GIT_SOURCE=git+https://github.com/org/repo@main") { + t.Errorf("expected -X main.GIT_SOURCE, got %q", got) + } + if strings.Contains(got, "INSTALL_WHEELS") { + t.Errorf("git build must not set INSTALL_WHEELS, got %q", got) + } +} + +// TestBuildGoBuildLdflags_GitDoesNotOmitOnWheels covers the edge case +// where both inputs are set. CLI validation enforces mutual exclusion at +// the command layer; this test just verifies the helper itself still emits +// the git flag if both happen to be passed. +func TestBuildGoBuildLdflags_GitDoesNotOmitOnWheels(t *testing.T) { + got := buildGoBuildLdflags("git+https://github.com/org/repo", []string{"some.whl"}) + if !strings.Contains(got, "-X main.GIT_SOURCE=git+https://github.com/org/repo") { + t.Errorf("expected -X main.GIT_SOURCE in output, got %q", got) + } +} From 5406228a92a14a4689d976a88adde1f5bfd0d38c Mon Sep 17 00:00:00 2001 From: hasansezertasan Date: Fri, 10 Apr 2026 16:26:39 +0300 Subject: [PATCH 07/17] feat(boxer): add 'uvbox git' subcommand and wire ldflag helper into goBuild - Replace inline ldflags construction in goBuild with buildGoBuildLdflags(GitSource, WheelsToEmbed) - Add GitSource package-level var alongside Config/Output/Nfpm/ReleaseVersion - Register gitCmd cobra subcommand (ExactArgs(1), same flags as pypiCmd/wheelCmd) - Add validateGitSourceFlag helper, called from preRun Co-Authored-By: Claude Sonnet 4.6 --- boxer/main.go | 48 +++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 43 insertions(+), 5 deletions(-) diff --git a/boxer/main.go b/boxer/main.go index 8e1dbff..08992d2 100644 --- a/boxer/main.go +++ b/boxer/main.go @@ -31,6 +31,7 @@ var Config string var Output string var Nfpm string var ReleaseVersion string +var GitSource string var Darwin bool var Linux bool @@ -104,6 +105,35 @@ func main() { wheelCmd.Flags().BoolVarP(&Amd, "amd", "", false, "Build for AMD64") wheelCmd.Flags().BoolVarP(&Arm, "arm", "", false, "build for ARM64") + // UVBOX GIT + var gitCmd = &cobra.Command{ + Use: "git ", + Short: "Use a git repository as package source to generate a standalone executable", + Long: "Use a git repository as package source to generate a standalone executable.\n\n" + + "The git spec is passed through to `uv tool install --from` verbatim.\n" + + "Examples:\n" + + " uvbox git git+https://github.com/org/repo\n" + + " uvbox git git+https://github.com/org/repo@main\n" + + " uvbox git git+https://github.com/org/repo@v1.0.0", + Args: cobra.ExactArgs(1), + PreRun: func(cmd *cobra.Command, args []string) { + GitSource = args[0] + preRun() + }, + Run: func(cmd *cobra.Command, args []string) { + if err := run(); err != nil { + logger.Fatal("failed to run git command", logger.Args("error", err)) + } + }, + } + gitCmd.Flags().StringVarP(&Config, "config", "c", "", "Configuration file") + gitCmd.Flags().StringVarP(&Output, "output", "o", "dist", "Output directory") + gitCmd.Flags().BoolVarP(&Darwin, "darwin", "d", false, "Build for darwin") + gitCmd.Flags().BoolVarP(&Linux, "linux", "l", false, "Build for linux") + gitCmd.Flags().BoolVarP(&Windows, "windows", "w", false, "Build for windows") + gitCmd.Flags().BoolVarP(&Amd, "amd", "", false, "Build for AMD64") + gitCmd.Flags().BoolVarP(&Arm, "arm", "", false, "build for ARM64") + // UVBOX var rootCmd = &cobra.Command{ Use: "uvbox", @@ -112,6 +142,7 @@ func main() { } rootCmd.AddCommand(pypiCmd) rootCmd.AddCommand(wheelCmd) + rootCmd.AddCommand(gitCmd) rootCmd.PersistentFlags().StringVarP(&ReleaseVersion, "release-version", "", "0.0.0", "Specify a version for the binaries. Will be used for example for versioning linux packages.") rootCmd.PersistentFlags().BoolVarP(&NoBanner, "no-banner", "", false, "Do not display the banner") rootCmd.PersistentFlags().StringVarP(&Nfpm, "nfpm", "", "", "Generate linux packages with the given nfpm configuration file") @@ -127,6 +158,7 @@ func preRun() { validateGoAvailability() validateNfpmAvailability() validateWheelsToEmbed() + validateGitSourceFlag() } func validateOutputDirectoryFlag() { @@ -161,6 +193,15 @@ func validateWheelsToEmbed() { } } +func validateGitSourceFlag() { + if GitSource == "" { + return + } + if err := validateGitSource(GitSource); err != nil { + logger.Fatal("invalid git source", logger.Args("error", err)) + } +} + type CompilationConfiguration struct { OS string ARCH string @@ -328,11 +369,8 @@ func goGenerate(repository, platform, arch string) error { func goBuild(repository, buildName, platform, arch string) error { var outbuf, errbuf strings.Builder - // Compilation flag - ldflags := "-s -w" - if len(WheelsToEmbed) > 0 { - ldflags += " -X main.INSTALL_WHEELS=yes" - } + // Compilation flag — see boxer/git.go for the pure helper and its tests. + ldflags := buildGoBuildLdflags(GitSource, WheelsToEmbed) // Command cmd := exec.Command("go", "build", "-o", buildName) From 84ebbde8c19b88af1595aa761307b23ed8388861 Mon Sep 17 00:00:00 2001 From: hasansezertasan Date: Fri, 10 Apr 2026 16:27:21 +0300 Subject: [PATCH 08/17] test(boxer): add unit tests for validateGitSource Co-Authored-By: Claude Opus 4.6 (1M context) --- boxer/git_test.go | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/boxer/git_test.go b/boxer/git_test.go index 135b004..ead031a 100644 --- a/boxer/git_test.go +++ b/boxer/git_test.go @@ -48,3 +48,44 @@ func TestBuildGoBuildLdflags_GitDoesNotOmitOnWheels(t *testing.T) { t.Errorf("expected -X main.GIT_SOURCE in output, got %q", got) } } + +func TestValidateGitSource_Empty(t *testing.T) { + if err := validateGitSource(""); err == nil { + t.Fatal("expected error for empty git source, got nil") + } +} + +func TestValidateGitSource_MissingGitPrefix(t *testing.T) { + cases := []string{ + "https://github.com/org/repo", + "http://github.com/org/repo", + "github.com/org/repo", + "ssh://git@github.com/org/repo", + "file:///tmp/repo", + } + for _, s := range cases { + t.Run(s, func(t *testing.T) { + if err := validateGitSource(s); err == nil { + t.Fatalf("expected error for %q, got nil", s) + } + }) + } +} + +func TestValidateGitSource_AcceptsValidSpecs(t *testing.T) { + cases := []string{ + "git+https://github.com/org/repo", + "git+https://github.com/org/repo@main", + "git+https://github.com/org/repo@v1.0.0", + "git+https://github.com/org/repo@abc123def456", + "git+ssh://git@github.com/org/repo", + "git+ssh://git@github.com/org/repo@main", + } + for _, s := range cases { + t.Run(s, func(t *testing.T) { + if err := validateGitSource(s); err != nil { + t.Fatalf("expected %q to be valid, got error: %v", s, err) + } + }) + } +} From 44568adbd7e6cb3f85dd78d2b6e39d54f19a8235 Mon Sep 17 00:00:00 2001 From: hasansezertasan Date: Fri, 10 Apr 2026 16:27:47 +0300 Subject: [PATCH 09/17] docs(examples): add git source example configuration Co-Authored-By: Claude Opus 4.6 (1M context) --- examples/git/simple-app.toml | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 examples/git/simple-app.toml diff --git a/examples/git/simple-app.toml b/examples/git/simple-app.toml new file mode 100644 index 0000000..82394d2 --- /dev/null +++ b/examples/git/simple-app.toml @@ -0,0 +1,20 @@ +# Build a uvbox binary from a git repository. +# +# Usage: +# uvbox git git+https://github.com// --config examples/git/simple-app.toml +# +# The git spec is passed through to `uv tool install --from` at runtime, so +# any form uv accepts works: @main, @v1.0.0, @, ssh:// URLs, etc. +# +# [package].name must match the distribution name the package registers +# (what `uv tool list` reports after installation). +# [package].script must match an entry from the package's [project.scripts]. + +[package] +name = "my-app" +script = "my-app" + +# To re-resolve the git ref on every run (equivalent to pycrucible's +# delete_after_run=true), uncomment: +# [package.version] +# auto-update = true From 7922290d850df17f8e13d3b37023e471666b788a Mon Sep 17 00:00:00 2001 From: hasansezertasan Date: Fri, 10 Apr 2026 16:28:12 +0300 Subject: [PATCH 10/17] docs(readme): add git source feature and usage documentation Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 38 +++++++++++++++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 8c5f359..97be842 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 --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 From 0edafb7de0180e5200f3503fb7a180a4091129fd Mon Sep 17 00:00:00 2001 From: hasansezertasan Date: Fri, 10 Apr 2026 16:55:47 +0300 Subject: [PATCH 11/17] fix(boxer,box): embed git source via file instead of ldflag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The smoke test surfaced a pre-existing bug in boxer's ldflag handling: Go's GOFLAGS parser uses strings.Fields (whitespace split, no quote handling), so any ldflag string containing -X flags fails to parse with "unknown flag -X". This silently broke the wheel path in commit f19680f when ldflags moved to GOFLAGS for Windows compatibility, and would have broken git the same way. To unblock the git feature without touching the wheel path (which remains broken in main and needs a separate fix), embed the git source via a committed placeholder file box/git_source.txt + //go:embed in box/box_package_git.go. boxer writes the git spec into this file before go build via writeGitSourceFile, replacing the empty placeholder for git builds and leaving it empty for pypi/wheel builds. This sidesteps GOFLAGS entirely for git. The wheel path's GOFLAGS bug is left untouched and should be addressed in a separate PR by the original author of f19680f, who can verify the Windows fix properly. Verified end-to-end: uvbox git git+https://github.com/VaasuDevanS/cowsay-python --darwin --arm ./dist/cowsay -t "uvbox git works!" # prints cow ✓ ./cowsay self update # "Already up-to-date" ✓ ./cowsay self path # cowsay-637049bd... ✓ uvbox pypi (regression) # cowsay-2fea8a21... ✓ different dir Co-Authored-By: Claude Opus 4.6 (1M context) --- box/box_package_git.go | 26 +++++++++++++++++----- box/git_source.txt | 0 boxer/git.go | 35 ++++++++++++++++++++--------- boxer/git_test.go | 50 +++++++++++++++++++++++++----------------- boxer/main.go | 11 +++++++++- 5 files changed, 86 insertions(+), 36 deletions(-) create mode 100644 box/git_source.txt diff --git a/box/box_package_git.go b/box/box_package_git.go index 1237879..e628500 100644 --- a/box/box_package_git.go +++ b/box/box_package_git.go @@ -1,16 +1,32 @@ package main import ( + _ "embed" "fmt" "os" "os/exec" + "strings" ) -// GIT_SOURCE is populated via ldflags at build time (-X main.GIT_SOURCE=git+...) -// when the binary is produced by `uvbox git `. When empty, the binary was -// produced by `uvbox pypi` or `uvbox wheel` and the runtime install path skips -// the git dispatch entirely. -var GIT_SOURCE = "" +// gitSourceContent is the raw content of git_source.txt, embedded at build +// time. For `uvbox git ` 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 `. 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 --upgrade`, optionally diff --git a/box/git_source.txt b/box/git_source.txt new file mode 100644 index 0000000..e69de29 diff --git a/boxer/git.go b/boxer/git.go index a20bd50..4e6efc7 100644 --- a/boxer/git.go +++ b/boxer/git.go @@ -2,35 +2,50 @@ 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. The arguments mirror the inputs the caller -// has at the point of invocation (gitSource CLI arg, WheelsToEmbed list). +// 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). -// - If a git source is set, adds "-X main.GIT_SOURCE=". // -// The git and wheel flags are independent: validateGitSource + CLI command -// separation ensure only one of the two is actually set in practice. This -// function does not enforce mutual exclusion; the CLI layer does. -func buildGoBuildLdflags(gitSource string, wheelsToEmbed []string) string { +// 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=` 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" } - if gitSource != "" { - ldflags += fmt.Sprintf(" -X main.GIT_SOURCE=%s", gitSource) - } return ldflags } +// writeGitSourceFile writes the provided git source string to +// /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 diff --git a/boxer/git_test.go b/boxer/git_test.go index ead031a..6e5854b 100644 --- a/boxer/git_test.go +++ b/boxer/git_test.go @@ -1,15 +1,17 @@ package main import ( - "strings" + "os" + "path/filepath" "testing" ) // TestBuildGoBuildLdflags_Pypi is a regression guard: the ldflag string // produced for a plain pypi build must match what goBuild used to produce -// before git support was added. +// before git support was added. Git support does NOT inject ldflags +// (see boxer/git.go for why) so adding it must not change this output. func TestBuildGoBuildLdflags_Pypi(t *testing.T) { - got := buildGoBuildLdflags("", nil) + got := buildGoBuildLdflags(nil) want := "-s -w" if got != want { t.Fatalf("buildGoBuildLdflags pypi = %q, want %q", got, want) @@ -18,34 +20,42 @@ func TestBuildGoBuildLdflags_Pypi(t *testing.T) { // TestBuildGoBuildLdflags_Wheel is a regression guard for the wheel path. func TestBuildGoBuildLdflags_Wheel(t *testing.T) { - got := buildGoBuildLdflags("", []string{"some-wheel.whl"}) + got := buildGoBuildLdflags([]string{"some-wheel.whl"}) want := "-s -w -X main.INSTALL_WHEELS=yes" if got != want { t.Fatalf("buildGoBuildLdflags wheel = %q, want %q", got, want) } } -func TestBuildGoBuildLdflags_Git(t *testing.T) { - got := buildGoBuildLdflags("git+https://github.com/org/repo@main", nil) - if !strings.Contains(got, "-s -w") { - t.Errorf("expected base flags -s -w, got %q", got) +func TestWriteGitSourceFile_Empty(t *testing.T) { + dir := t.TempDir() + if err := writeGitSourceFile(dir, ""); err != nil { + t.Fatalf("writeGitSourceFile empty failed: %v", err) } - if !strings.Contains(got, "-X main.GIT_SOURCE=git+https://github.com/org/repo@main") { - t.Errorf("expected -X main.GIT_SOURCE, got %q", got) + + got, err := os.ReadFile(filepath.Join(dir, "git_source.txt")) + if err != nil { + t.Fatalf("failed to read written file: %v", err) } - if strings.Contains(got, "INSTALL_WHEELS") { - t.Errorf("git build must not set INSTALL_WHEELS, got %q", got) + if string(got) != "" { + t.Fatalf("expected empty file, got %q", string(got)) } } -// TestBuildGoBuildLdflags_GitDoesNotOmitOnWheels covers the edge case -// where both inputs are set. CLI validation enforces mutual exclusion at -// the command layer; this test just verifies the helper itself still emits -// the git flag if both happen to be passed. -func TestBuildGoBuildLdflags_GitDoesNotOmitOnWheels(t *testing.T) { - got := buildGoBuildLdflags("git+https://github.com/org/repo", []string{"some.whl"}) - if !strings.Contains(got, "-X main.GIT_SOURCE=git+https://github.com/org/repo") { - t.Errorf("expected -X main.GIT_SOURCE in output, got %q", got) +func TestWriteGitSourceFile_WithSpec(t *testing.T) { + dir := t.TempDir() + spec := "git+https://github.com/org/repo@main" + + if err := writeGitSourceFile(dir, spec); err != nil { + t.Fatalf("writeGitSourceFile failed: %v", err) + } + + got, err := os.ReadFile(filepath.Join(dir, "git_source.txt")) + if err != nil { + t.Fatalf("failed to read written file: %v", err) + } + if string(got) != spec { + t.Fatalf("expected %q, got %q", spec, string(got)) } } diff --git a/boxer/main.go b/boxer/main.go index 08992d2..34113a9 100644 --- a/boxer/main.go +++ b/boxer/main.go @@ -249,6 +249,13 @@ func insertFilesIntoBoxRepository(boxRepository string) error { } } + // Write the git source file (always — empty for pypi/wheel builds, + // populated for `uvbox git ` builds). See boxer/git.go and + // box/box_package_git.go for the embed mechanism. + if err := writeGitSourceFile(boxRepository, GitSource); err != nil { + return fmt.Errorf("failed to write git source file: %w", err) + } + return nil } @@ -370,7 +377,9 @@ func goBuild(repository, buildName, platform, arch string) error { var outbuf, errbuf strings.Builder // Compilation flag — see boxer/git.go for the pure helper and its tests. - ldflags := buildGoBuildLdflags(GitSource, WheelsToEmbed) + // Git source is NOT injected via ldflags (Go's GOFLAGS parser rejects + // `-X main.FOO=bar`); it is embedded via box/git_source.txt instead. + ldflags := buildGoBuildLdflags(WheelsToEmbed) // Command cmd := exec.Command("go", "build", "-o", buildName) From 597b8ac2a1e493ef2ae5426179fae941422bce28 Mon Sep 17 00:00:00 2001 From: hasansezertasan Date: Fri, 10 Apr 2026 17:04:38 +0300 Subject: [PATCH 12/17] chore(box): generate git_source.txt placeholder via box/generate.go Match the existing wheels/placeholder pattern: gitignore the dynamically generated file and create it on demand via go generate. Keeps the box package free of empty committed files and lets `mise run generate:box` handle bootstrapping for tests. Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 1 + box/generate.go | 5 +++++ box/git_source.txt | 0 3 files changed, 6 insertions(+) delete mode 100644 box/git_source.txt diff --git a/.gitignore b/.gitignore index 5eadf1e..8ff07c6 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ box/uvbox.toml box/wheels +box/git_source.txt boxer/boxes boxer/boxer diff --git a/box/generate.go b/box/generate.go index d4ca121..f21f559 100644 --- a/box/generate.go +++ b/box/generate.go @@ -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) { @@ -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 ` is used). + generateEmptyFileIfMissing(GIT_SOURCE_FILENAME) } diff --git a/box/git_source.txt b/box/git_source.txt deleted file mode 100644 index e69de29..0000000 From 8650c50445180dac36f60bd14703915c5d425d3f Mon Sep 17 00:00:00 2001 From: hasansezertasan Date: Sat, 11 Apr 2026 16:13:33 +0300 Subject: [PATCH 13/17] docs: point git example at cowsay and list it in README Use the VaasuDevanS/cowsay-python repo in examples/git/simple-app.toml so the example is runnable as-is, and add a git entry to the README examples list next to the existing pypi entry. Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 3 ++- examples/git/simple-app.toml | 9 ++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 97be842..2019358 100644 --- a/README.md +++ b/README.md @@ -408,7 +408,8 @@ auto-update = true See the [`examples/`](./examples) directory for complete working examples: -- [`simple-app.toml`](./examples/pypi/simple-app.toml) — Minimal PyPI package +- [`simple-app.toml`](./examples/git/simple-app.toml) — Minimal Python Package from Git repository +- [`simple-app.toml`](./examples/pypi/simple-app.toml) — Minimal Python Package from PyPI - [`custom-registry.toml`](./examples/pypi/custom-registry.toml) — Custom registry and mirrors - [`custom-certs.toml`](./examples/pypi/custom-certs.toml) — Corporate CA bundle - [`optional-dependency.toml`](./examples/pypi/optional-dependency.toml) - Install a package with an optional dependency diff --git a/examples/git/simple-app.toml b/examples/git/simple-app.toml index 82394d2..3a99dec 100644 --- a/examples/git/simple-app.toml +++ b/examples/git/simple-app.toml @@ -1,7 +1,7 @@ # Build a uvbox binary from a git repository. # # Usage: -# uvbox git git+https://github.com// --config examples/git/simple-app.toml +# uvbox git git+https://github.com/VaasuDevanS/cowsay-python --config examples/git/simple-app.toml # # The git spec is passed through to `uv tool install --from` at runtime, so # any form uv accepts works: @main, @v1.0.0, @, ssh:// URLs, etc. @@ -11,10 +11,9 @@ # [package].script must match an entry from the package's [project.scripts]. [package] -name = "my-app" -script = "my-app" +name = "cowsay" +script = "cowsay" -# To re-resolve the git ref on every run (equivalent to pycrucible's -# delete_after_run=true), uncomment: +# To re-resolve the git ref on every run, uncomment: # [package.version] # auto-update = true From e11a26a00947ff88b5464c8be6bcf6ea3429d78a Mon Sep 17 00:00:00 2001 From: hasansezertasan Date: Sat, 11 Apr 2026 16:18:02 +0300 Subject: [PATCH 14/17] docs(readme): align git-source docs with actual PR #22 behavior - Surface all three source subcommands (pypi/wheel/git) in Basic Usage so readers don't need to scroll to discover git support. - Use the real VaasuDevanS/cowsay-python repo in git command examples so they are copy-paste runnable against examples/git/simple-app.toml. - Generalise [package].name / script reference docs: they apply to all source types, and for git the name must match the distribution name the repo's pyproject.toml registers (not the repo slug). - Clarify auto-update semantics for git builds: always-update behavior only holds when [package.version] is unset; pinning static/dynamic to the installed version disables it via the version-compare short-circuit in box/main.go. - Disambiguate Examples list entries by including the directory prefix (both simple-app.toml files were rendering with identical link text). Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 52 ++++++++++++++++++++++++++++++++++------------------ 1 file changed, 34 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 2019358..8bb628a 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,12 @@ pip install uvbox ### Basic Usage +uvbox supports three source types, each via its own subcommand: + +- **`uvbox pypi`** — install the package from a package index (PyPI by default) +- **`uvbox wheel`** — bundle one or more local wheel files into the binary +- **`uvbox git`** — fetch the package from a git repository at runtime + Create a simple configuration and build: ```bash @@ -118,20 +124,22 @@ 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: +Package an application from a git repository that isn't published to PyPI. +The examples below use [`VaasuDevanS/cowsay-python`](https://github.com/VaasuDevanS/cowsay-python) +so they're copy-paste runnable — pair them with [`examples/git/simple-app.toml`](./examples/git/simple-app.toml): ```bash # Default branch -uvbox git git+https://github.com/org/repo --config uvbox.toml +uvbox git git+https://github.com/VaasuDevanS/cowsay-python --config examples/git/simple-app.toml # Specific tag -uvbox git git+https://github.com/org/repo@v1.0.0 +uvbox git git+https://github.com/VaasuDevanS/cowsay-python@v6.1 # Specific branch -uvbox git git+https://github.com/org/repo@main +uvbox git git+https://github.com/VaasuDevanS/cowsay-python@main # Specific commit -uvbox git git+https://github.com/org/repo@abc123 +uvbox git git+https://github.com/VaasuDevanS/cowsay-python@abc123 # SSH (uses your local git credentials) uvbox git git+ssh://git@github.com/org/private-repo @@ -145,12 +153,16 @@ 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 --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. + the spec is the source of truth. `uvToolInstallGit` never consults them. +- With `auto-update = true` and no `[package.version]` set, uvbox re-runs + `uv tool install --from --upgrade` on every invocation, giving you + the "fresh dependencies every run" behavior equivalent to `pycrucible`'s + `delete_after_run = true`. This works because the internal version + comparison treats the unset case as always-stale. +- Setting `static = "x.y.z"` or a `dynamic` URL that resolves to the + currently installed version will **disable** the always-update behavior: + the version compare matches, and the update path is skipped. Leave both + unset when tracking a moving branch. ## Configuration @@ -236,8 +248,12 @@ environment = [ #### `[package]` Core package configuration. -- **`name`** (required) — Package name to install from PyPI -- **`script`** (required) — Entry point script to run (from `[project.scripts]` in your package) +- **`name`** (required) — Distribution name the package registers (what + `uv tool list` reports after install). Used for all source types + (`pypi`, `wheel`, `git`) — for `git` it must match the name declared in + the repo's `pyproject.toml`, not the repo/org slug. +- **`script`** (required) — Entry point to run. Must match an entry from + the package's `[project.scripts]`. Applies to all source types. #### `[package.version]` Version management and updates. @@ -408,11 +424,11 @@ auto-update = true See the [`examples/`](./examples) directory for complete working examples: -- [`simple-app.toml`](./examples/git/simple-app.toml) — Minimal Python Package from Git repository -- [`simple-app.toml`](./examples/pypi/simple-app.toml) — Minimal Python Package from PyPI -- [`custom-registry.toml`](./examples/pypi/custom-registry.toml) — Custom registry and mirrors -- [`custom-certs.toml`](./examples/pypi/custom-certs.toml) — Corporate CA bundle -- [`optional-dependency.toml`](./examples/pypi/optional-dependency.toml) - Install a package with an optional dependency +- [`git/simple-app.toml`](./examples/git/simple-app.toml) — Minimal Python package from a Git repository +- [`pypi/simple-app.toml`](./examples/pypi/simple-app.toml) — Minimal Python package from PyPI +- [`pypi/custom-registry.toml`](./examples/pypi/custom-registry.toml) — Custom registry and mirrors +- [`pypi/custom-certs.toml`](./examples/pypi/custom-certs.toml) — Corporate CA bundle +- [`pypi/optional-dependency.toml`](./examples/pypi/optional-dependency.toml) — Install a package with an optional dependency ## Requirements From 13b68c37fa7644739dd0eba65de2ec5821cb11d2 Mon Sep 17 00:00:00 2001 From: hasansezertasan Date: Sat, 11 Apr 2026 16:20:18 +0300 Subject: [PATCH 15/17] docs(readme): note git runtime requirement for uvbox git builds uv shells out to the system git binary when resolving git+ sources, so binaries produced by 'uvbox git' need git installed on the end-user machine at first run (or whenever auto-update re-installs). Also call out that ssh:// specs use the user's local SSH setup. Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index 8bb628a..7bc0c9e 100644 --- a/README.md +++ b/README.md @@ -440,6 +440,9 @@ See the [`examples/`](./examples) directory for complete working examples: ### Runtime (Generated Binaries) - **libc** (standard C library, required by Python itself) +- **git** (only for binaries built with `uvbox git` — `uv` shells out to the + system `git` on first run to clone the repository. For `ssh://` specs, the + end user's local SSH keys/agent are used for authentication.) ## License From eacaf5cb99e6985a86bbed4784afd247b2d874b3 Mon Sep 17 00:00:00 2001 From: hasansezertasan Date: Sat, 11 Apr 2026 16:36:29 +0300 Subject: [PATCH 16/17] fix(box,boxer): address PR #22 review findings - Propagate constraints-file download error instead of swallowing at Debug - Extract selectInstallMethod pure helper; reject git+wheels conflict at both build time (boxer preRun) and runtime (box dispatch) - uvToolInstallGit now takes packageVersion and warns when non-empty, so users who set [package.version].static on a git build get a visible breadcrumb instead of silent ignore - Capture uv stdout into a buffer in uvToolInstallGit and include it in the returned error, preserving resolver/auth context on failure - Deepen validateGitSource with net/url parsing: reject prefix-only, scheme typos, unsupported schemes, and missing host - Add TestSelectInstallMethod dispatch matrix, malformed-spec validator cases, and a no-op guard test for validateGitSourceFlag - Reconcile README [package.version] section with actual dispatch flow, add golden-hash regeneration recipe, and trim duplicated comments Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 21 ++++++------ box/box_package.go | 45 +++++++++++++++++++++---- box/box_package_git.go | 63 +++++++++++++++++++++++++---------- box/box_package_test.go | 38 +++++++++++++++++++++ box/config_identifier_test.go | 16 ++++++++- boxer/git.go | 57 ++++++++++++++++++++++++++----- boxer/git_test.go | 32 ++++++++++++++++++ boxer/main.go | 16 +++++---- 8 files changed, 238 insertions(+), 50 deletions(-) create mode 100644 box/box_package_test.go diff --git a/README.md b/README.md index 7bc0c9e..ef9a8fa 100644 --- a/README.md +++ b/README.md @@ -153,16 +153,17 @@ 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. `uvToolInstallGit` never consults them. -- With `auto-update = true` and no `[package.version]` set, uvbox re-runs - `uv tool install --from --upgrade` on every invocation, giving you - the "fresh dependencies every run" behavior equivalent to `pycrucible`'s - `delete_after_run = true`. This works because the internal version - comparison treats the unset case as always-stale. -- Setting `static = "x.y.z"` or a `dynamic` URL that resolves to the - currently installed version will **disable** the always-update behavior: - the version compare matches, and the update path is skipped. Leave both - unset when tracking a moving branch. + the spec is the source of truth. `uvToolInstallGit` logs a warning and + drops any supplied version. Leave both unset when tracking a moving branch. +- With `auto-update = true` and neither `static` nor `dynamic` set, the + runtime version check has no target to compare against and falls through + to re-running `uv tool install --from --upgrade` on every + invocation, giving you the "fresh dependencies every run" behavior + equivalent to `pycrucible`'s `delete_after_run = true`. +- Setting `static = "x.y.z"` or a `dynamic` URL that happens to resolve to + the currently installed version will **disable** the always-update + behavior — the outer version compare matches and skips the update path + before `uvToolInstallGit` ever runs. ## Configuration diff --git a/box/box_package.go b/box/box_package.go index 356069c..08cec2f 100644 --- a/box/box_package.go +++ b/box/box_package.go @@ -92,7 +92,7 @@ func (b *Box) InstallPackage(packageVersion, packageConstraintsUrl string) error if packageConstraintsUrl != "" { file, err := downloadTemporaryFile(packageConstraintsUrl) if err != nil { - logger.Debug("Failed to download constraints file", logger.Args("error", err)) + return fmt.Errorf("failed to download constraints file from %s: %w", packageConstraintsUrl, err) } constraintsFile = file } @@ -156,14 +156,47 @@ func (b *Box) InstalledPackagePath() (string, error) { return installedPackage.Path, nil } +// installMethod identifies which install backend uvToolInstall should use. +type installMethod int + +const ( + installMethodPypi installMethod = iota + installMethodWheels + installMethodGit +) + +// selectInstallMethod is the pure-function core of the uvToolInstall dispatch. +// It returns an error when the build-time configuration is inconsistent — +// specifically when both GIT_SOURCE and INSTALL_WHEELS are set, which would +// otherwise silently favor one source and discard the other. +func selectInstallMethod(gitSource, installWheels string) (installMethod, error) { + gitSet := gitSource != "" + wheelsSet := installWheels == "yes" + if gitSet && wheelsSet { + return 0, fmt.Errorf("invalid binary: both GIT_SOURCE and INSTALL_WHEELS are set; this indicates a build-time bug, please rebuild") + } + switch { + case gitSet: + return installMethodGit, nil + case wheelsSet: + return installMethodWheels, nil + default: + return installMethodPypi, nil + } +} + func (b *Box) uvToolInstall(packageVersion, constraintsFile string) error { - if GIT_SOURCE != "" { - return b.uvToolInstallGit(constraintsFile) + method, err := selectInstallMethod(GIT_SOURCE, INSTALL_WHEELS) + if err != nil { + return err } - if INSTALL_WHEELS == "no" { - return b.uvToolInstallPypi(packageVersion, constraintsFile) - } else { + switch method { + case installMethodGit: + return b.uvToolInstallGit(packageVersion, constraintsFile) + case installMethodWheels: return b.uvToolInstallWheels(constraintsFile) + default: + return b.uvToolInstallPypi(packageVersion, constraintsFile) } } diff --git a/box/box_package_git.go b/box/box_package_git.go index e628500..03b51fe 100644 --- a/box/box_package_git.go +++ b/box/box_package_git.go @@ -1,8 +1,10 @@ package main import ( + "bytes" _ "embed" "fmt" + "io" "os" "os/exec" "strings" @@ -10,13 +12,16 @@ import ( // gitSourceContent is the raw content of git_source.txt, embedded at build // time. For `uvbox git ` 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. +// file before invoking `go build`. For pypi/wheel builds, the file is +// created empty by box/generate.go (it is gitignored — not a committed +// file) so the //go:embed directive always has a target. // // 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. +// the Go toolchain splits GOFLAGS on whitespace, so any ldflag value +// containing spaces (or `-X main.FOO=bar` that uses an `=` plus another +// flag) breaks parsing. The boxer build path routes ldflags through GOFLAGS +// for Windows compatibility (see issue AmadeusITGroup/uvbox#7 and the +// comment above buildGoBuildLdflags in boxer/git.go). // //go:embed git_source.txt var gitSourceContent string @@ -24,17 +29,24 @@ 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 `. 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. +// entirely. strings.TrimSpace tolerates a trailing newline if the writer +// ever grows one. var GIT_SOURCE = strings.TrimSpace(gitSourceContent) // buildUvToolInstallFromArgs constructs the command-line arguments for // `uv tool install --from --upgrade`, optionally -// appending `--with-requirements `. Pure function: no -// side effects, easy to unit-test. +// appending `--with-requirements `. Pulled out as a +// standalone helper so it can be tested without invoking `uv`. // -// The `--upgrade` flag is always included for git sources because there is -// no "pinned version" concept — every install/update must re-resolve the ref. +// `--upgrade` is always included because uv has no cached version identity +// for a git install — the ref in the spec is opaque to uv's upgrade check, +// so forcing the re-install path is the only way to pick up new commits +// for a moving ref like `@main`. Whether this function is *called* at all +// is still gated by the outer version-check path in box/main.go — see the +// README section "Behavior of [package.version] for git builds". +// +// Constraints apply to the transitive dependency resolution — the primary +// package is pinned by the git ref itself. func buildUvToolInstallFromArgs(uvPath, gitSource, packageName, constraintsFile string) []string { args := []string{ uvPath, @@ -53,10 +65,19 @@ func buildUvToolInstallFromArgs(uvPath, gitSource, packageName, constraintsFile } // uvToolInstallGit installs the package from the embedded GIT_SOURCE via -// `uv tool install --from --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 { +// `uv tool install --from --upgrade`. +// +// packageVersion is accepted for signature parity with uvToolInstallPypi, +// but is intentionally not used to construct the install spec: the git ref +// in GIT_SOURCE is the source of truth. If the caller supplies a non-empty +// packageVersion (i.e., the user set [package.version].static on a git +// build), a user-visible warning is emitted so the mismatch is not silent. +func (b *Box) uvToolInstallGit(packageVersion, constraintsFile string) error { + if packageVersion != "" { + logger.Warn("Ignoring [package.version] for git source; the git ref in GIT_SOURCE is the source of truth", + logger.Args("ignoredVersion", packageVersion, "gitSource", GIT_SOURCE)) + } + logger.Debug("Installing package from git", logger.Args("name", b.PackageName, "source", GIT_SOURCE, "constraintsFile", constraintsFile)) @@ -72,16 +93,24 @@ func (b *Box) uvToolInstallGit(constraintsFile string) error { return fmt.Errorf("could not get uv environment variables: %w", err) } + // Capture stdout unconditionally into a buffer so that, on failure, + // uv's resolver/auth output is included in the returned error rather + // than silently discarded. In verbose modes we also stream it to the + // user's terminal live. + var stdout bytes.Buffer cmd := exec.Command(commandArgs[0], commandArgs[1:]...) cmd.Env = env cmd.Stderr = os.Stderr if debugEnabled() || traceEnabled() { - cmd.Stdout = os.Stdout + cmd.Stdout = io.MultiWriter(os.Stdout, &stdout) + } else { + cmd.Stdout = &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) + return fmt.Errorf("uv failed to install %q from git source %q: %w\nuv output:\n%s", + b.PackageName, GIT_SOURCE, err, stdout.String()) } logger.Debug("Installed", logger.Args("package", b.PackageName)) diff --git a/box/box_package_test.go b/box/box_package_test.go new file mode 100644 index 0000000..f9e0cad --- /dev/null +++ b/box/box_package_test.go @@ -0,0 +1,38 @@ +package main + +import "testing" + +func TestSelectInstallMethod(t *testing.T) { + cases := []struct { + name string + gitSource string + installWheels string + want installMethod + wantErr bool + }{ + {"pypi_default", "", "no", installMethodPypi, false}, + {"pypi_empty_install_wheels", "", "", installMethodPypi, false}, + {"wheels", "", "yes", installMethodWheels, false}, + {"git_no_wheels", "git+https://github.com/org/repo", "no", installMethodGit, false}, + {"git_empty_install_wheels", "git+https://github.com/org/repo@main", "", installMethodGit, false}, + {"conflict_git_and_wheels", "git+https://github.com/org/repo", "yes", 0, true}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := selectInstallMethod(tc.gitSource, tc.installWheels) + if tc.wantErr { + if err == nil { + t.Fatalf("expected error for git+wheels conflict, got method=%v", got) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tc.want { + t.Fatalf("selectInstallMethod(%q, %q) = %v, want %v", tc.gitSource, tc.installWheels, got, tc.want) + } + }) + } +} diff --git a/box/config_identifier_test.go b/box/config_identifier_test.go index 4d76d4f..7a25cde 100644 --- a/box/config_identifier_test.go +++ b/box/config_identifier_test.go @@ -21,7 +21,21 @@ func makeTestConfig() Configuration { // 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. +// and must not change when GIT_SOURCE is empty. If this hash changes, +// existing deployed pypi/wheel binaries will fail to find their +// installed package on next run — treat any change here as breaking. +// +// The hash is SHA1 of: +// +// c.Package.Name + c.Package.Script + c.Package.Version.Static + +// fmt.Sprintf("%t", c.AutoUpdateEnabled()) +// +// For makeTestConfig() above: "my-package" + "my-script" + "1.0.0" + "true". +// To regenerate after an intentional breaking change, run: +// +// cfg := makeTestConfig() +// GIT_SOURCE = "" +// fmt.Println(cfg.ComputeIdentifier()) func TestComputeIdentifier_StableForPypi(t *testing.T) { // Ensure GIT_SOURCE is empty for this test (it's the default, but be explicit). origGitSource := GIT_SOURCE diff --git a/boxer/git.go b/boxer/git.go index 4e6efc7..9dca2bf 100644 --- a/boxer/git.go +++ b/boxer/git.go @@ -2,6 +2,7 @@ package main import ( "fmt" + "net/url" "os" "path/filepath" "strings" @@ -18,11 +19,11 @@ import ( // 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=` 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. +// ldflags. The Go toolchain splits GOFLAGS on whitespace, so any ldflag +// string containing `-X main.FOO=bar` is broken across flag boundaries +// and the toolchain rejects `-X` as an unknown top-level flag. 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 { @@ -47,10 +48,16 @@ func writeGitSourceFile(boxRepository, gitSource string) error { } // 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. +// was provided. It enforces the uvbox-side format constraints so that +// obvious typos fail at build time rather than on every end-user machine: +// +// - must begin with "git+" +// - the URL after the "git+" prefix must parse +// - scheme must be one of http, https, ssh, file +// - non-file schemes must have a host +// +// Semantic ref resolution (branch, tag, commit existence) is still +// delegated to uv on first run of the generated binary. func validateGitSource(gitSource string) error { if gitSource == "" { return fmt.Errorf("git source must not be empty") @@ -58,5 +65,37 @@ func validateGitSource(gitSource string) error { 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) } + + raw := strings.TrimPrefix(gitSource, "git+") + if raw == "" { + return fmt.Errorf("git source is missing a URL after 'git+' prefix, got %q", gitSource) + } + + // Strip an optional trailing "@ref" before URL parsing, but only when + // the `@` is not part of a userinfo segment like "ssh://git@host/...". + // Any `@` appearing after the first `/` of the path component is an + // "@ref" suffix; userinfo `@` always precedes the host segment. + parseTarget := raw + if slash := strings.Index(raw, "/"); slash != -1 { + if at := strings.LastIndex(raw, "@"); at > slash { + parseTarget = raw[:at] + } + } + + u, err := url.Parse(parseTarget) + if err != nil { + return fmt.Errorf("git source %q is not a valid URL: %w", gitSource, err) + } + + switch u.Scheme { + case "http", "https", "ssh", "file": + default: + return fmt.Errorf("git source scheme %q is not supported; use http(s), ssh, or file (got %q)", u.Scheme, gitSource) + } + + if u.Scheme != "file" && u.Host == "" { + return fmt.Errorf("git source %q is missing a host", gitSource) + } + return nil } diff --git a/boxer/git_test.go b/boxer/git_test.go index 6e5854b..844b38a 100644 --- a/boxer/git_test.go +++ b/boxer/git_test.go @@ -88,8 +88,10 @@ func TestValidateGitSource_AcceptsValidSpecs(t *testing.T) { "git+https://github.com/org/repo@main", "git+https://github.com/org/repo@v1.0.0", "git+https://github.com/org/repo@abc123def456", + "git+https://github.com/org/repo@feature/new-thing", "git+ssh://git@github.com/org/repo", "git+ssh://git@github.com/org/repo@main", + "git+file:///tmp/repo", } for _, s := range cases { t.Run(s, func(t *testing.T) { @@ -99,3 +101,33 @@ func TestValidateGitSource_AcceptsValidSpecs(t *testing.T) { }) } } + +// TestValidateGitSource_RejectsMalformed covers the deeper URL-parsing +// checks: typos in scheme, missing host, prefix-only specs. +func TestValidateGitSource_RejectsMalformed(t *testing.T) { + cases := []string{ + "git+", // prefix only + "git+htps://github.com/org/repo", // scheme typo + "git+ftp://github.com/org/repo", // unsupported scheme + "git+https:///org/repo", // no host + "git+https://", // no host, no path + } + for _, s := range cases { + t.Run(s, func(t *testing.T) { + if err := validateGitSource(s); err == nil { + t.Fatalf("expected %q to be rejected, got nil", s) + } + }) + } +} + +// TestValidateGitSourceFlag_EmptyIsNoop guards the contract that pypi/wheel +// builds (which leave GitSource empty) are never affected by the git +// validation path. If this ever regresses, every pypi/wheel build breaks. +func TestValidateGitSourceFlag_EmptyIsNoop(t *testing.T) { + orig := GitSource + t.Cleanup(func() { GitSource = orig }) + GitSource = "" + // Must not call logger.Fatal. + validateGitSourceFlag() +} diff --git a/boxer/main.go b/boxer/main.go index 34113a9..7723667 100644 --- a/boxer/main.go +++ b/boxer/main.go @@ -197,8 +197,13 @@ func validateGitSourceFlag() { if GitSource == "" { return } + if len(WheelsToEmbed) > 0 { + logger.Fatal("cannot combine a git source with embedded wheels; use either `uvbox git` or `uvbox wheel`, not both") + } if err := validateGitSource(GitSource); err != nil { - logger.Fatal("invalid git source", logger.Args("error", err)) + logger.Fatal("invalid git source argument", + logger.Args("error", err, "providedValue", GitSource, + "hint", "expected form: git+https://host/org/repo[@ref] or git+ssh://git@host/org/repo[@ref]")) } } @@ -249,9 +254,7 @@ func insertFilesIntoBoxRepository(boxRepository string) error { } } - // Write the git source file (always — empty for pypi/wheel builds, - // populated for `uvbox git ` builds). See boxer/git.go and - // box/box_package_git.go for the embed mechanism. + // Always write — see writeGitSourceFile. if err := writeGitSourceFile(boxRepository, GitSource); err != nil { return fmt.Errorf("failed to write git source file: %w", err) } @@ -376,9 +379,8 @@ func goGenerate(repository, platform, arch string) error { func goBuild(repository, buildName, platform, arch string) error { var outbuf, errbuf strings.Builder - // Compilation flag — see boxer/git.go for the pure helper and its tests. - // Git source is NOT injected via ldflags (Go's GOFLAGS parser rejects - // `-X main.FOO=bar`); it is embedded via box/git_source.txt instead. + // See buildGoBuildLdflags in boxer/git.go for why git source is not + // injected via ldflags. ldflags := buildGoBuildLdflags(WheelsToEmbed) // Command From d646f8c579480f8d0faa6d7e3745bd0b670b1ca8 Mon Sep 17 00:00:00 2001 From: hasansezertasan Date: Mon, 13 Apr 2026 15:11:48 +0300 Subject: [PATCH 17/17] fix(box): address PR #22 review round 2 - Revert constraints download to non-fatal logger.Debug (optional file) - Unify log messages: "Installing package" with "method" key (pypi/git) - Match uvToolInstallGit stdout/error handling to uvToolInstallLine pattern Co-Authored-By: Claude Opus 4.6 (1M context) --- box/box_package.go | 3 ++- box/box_package_git.go | 22 +++++++--------------- 2 files changed, 9 insertions(+), 16 deletions(-) diff --git a/box/box_package.go b/box/box_package.go index 08cec2f..24b5946 100644 --- a/box/box_package.go +++ b/box/box_package.go @@ -92,7 +92,7 @@ func (b *Box) InstallPackage(packageVersion, packageConstraintsUrl string) error if packageConstraintsUrl != "" { file, err := downloadTemporaryFile(packageConstraintsUrl) if err != nil { - return fmt.Errorf("failed to download constraints file from %s: %w", packageConstraintsUrl, err) + logger.Debug("Failed to download constraints file", logger.Args("error", err)) } constraintsFile = file } @@ -246,6 +246,7 @@ func (b *Box) uvToolInstallPypi(packageVersion, constraintsFile string) error { "name": b.PackageName, "version": packageVersion, "constraintsFile": constraintsFile, + "method": "pypi", } logger.Debug("Installing package", logger.ArgsFromMap(debugArgsMap)) diff --git a/box/box_package_git.go b/box/box_package_git.go index 03b51fe..295acd2 100644 --- a/box/box_package_git.go +++ b/box/box_package_git.go @@ -1,10 +1,8 @@ package main import ( - "bytes" _ "embed" "fmt" - "io" "os" "os/exec" "strings" @@ -78,8 +76,8 @@ func (b *Box) uvToolInstallGit(packageVersion, constraintsFile string) error { logger.Args("ignoredVersion", packageVersion, "gitSource", GIT_SOURCE)) } - logger.Debug("Installing package from git", - logger.Args("name", b.PackageName, "source", GIT_SOURCE, "constraintsFile", constraintsFile)) + logger.Debug("Installing package", + logger.Args("name", b.PackageName, "source", GIT_SOURCE, "constraintsFile", constraintsFile, "method", "git")) uv, err := b.InstalledUvExecutablePath() if err != nil { @@ -93,24 +91,18 @@ func (b *Box) uvToolInstallGit(packageVersion, constraintsFile string) error { return fmt.Errorf("could not get uv environment variables: %w", err) } - // Capture stdout unconditionally into a buffer so that, on failure, - // uv's resolver/auth output is included in the returned error rather - // than silently discarded. In verbose modes we also stream it to the - // user's terminal live. - var stdout bytes.Buffer cmd := exec.Command(commandArgs[0], commandArgs[1:]...) cmd.Env = env cmd.Stderr = os.Stderr + // Enable Stdout if debug is enabled if debugEnabled() || traceEnabled() { - cmd.Stdout = io.MultiWriter(os.Stdout, &stdout) - } else { - cmd.Stdout = &stdout + cmd.Stdout = os.Stdout } logger.Trace("Running", logger.Args("command", commandArgs, "env", env)) - if err := cmd.Run(); err != nil { - return fmt.Errorf("uv failed to install %q from git source %q: %w\nuv output:\n%s", - b.PackageName, GIT_SOURCE, err, stdout.String()) + err = cmd.Run() + if err != nil { + return fmt.Errorf("failed to run command %v: %w", commandArgs, err) } logger.Debug("Installed", logger.Args("package", b.PackageName))