Skip to content
Open
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [v1.1.3] - 2026-10-10

### Fixed
- `service push` no longer runs `git prune` in the user's repository after packaging. The prune ran with the temporary index still selected, so every blob that was staged but not yet committed was deleted and the next `git commit` failed with `invalid object`. The temporary tree and commits are now written to a throwaway object directory outside the repository, which is simply removed afterwards. No object is created in or removed from the repository anymore, in any workspace state
- `service push` works from a linked `git worktree` (it used to fail with `exit status 128`, since the temporary index was created inside `.git`, which is a file there) and no longer leaks a temporary index file into `.git` when `--working-dir` points at a subdirectory

## [v1.1.2] - 2026-09-04

### Added
Expand Down
3 changes: 2 additions & 1 deletion src/archiveClient/handler_archiveGitFiles.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,10 +100,11 @@ func (h *Handler) createNoGitArchive(uxBlocks uxBlock.UxBlocks, workingDir strin
// createSimpleArchive creates an archive without the .git directory
func (h *Handler) createSimpleArchive(ctx context.Context, workingDir string, writer io.Writer) error {
archiver := newGitArchiver(workingDir, h.config.PushWorkspaceState, h.config.Verbose, h.config.Logger)
// Deferred before initialize so a half-built quarantine directory is removed when initialization fails.
defer archiver.cleanup()
if err := archiver.initialize(ctx); err != nil {
return err
}
defer archiver.cleanup(ctx)

// Run the git archive and pipe it to gzip
cmd := archiver.getArchiveCmd(ctx)
Expand Down
125 changes: 105 additions & 20 deletions src/archiveClient/handler_gitArchiver.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@ package archiveClient

import (
"context"
"io"
"os"
"path"
"path/filepath"
"strings"

"github.com/google/uuid"
"github.com/zeropsio/zcli/src/cmdRunner"
"github.com/zeropsio/zcli/src/logger"
)
Expand All @@ -16,7 +16,8 @@ type gitArchiver struct {
workingDir string
env []string
workspaceState string
indexFile string
// tmpDir holds the quarantine object directory and the temporary index. Removing it is the whole cleanup.
tmpDir string

verbose bool
logger logger.Logger
Expand All @@ -33,8 +34,18 @@ func newGitArchiver(workingDir string, workspaceState string, verbose bool, logg
}

func (h *gitArchiver) initialize(ctx context.Context) error {
if err := h.setupQuarantine(ctx); err != nil {
return err
}

switch h.workspaceState {
case WorkspaceStaged:
// `git write-tree` refreshes the cache-tree extension of the index it runs on, and done on the real index that would leave it pointing at trees which only exist in the quarantine.
// So the tree is written from a private copy of the index instead.
if err := h.copyIndex(ctx); err != nil {
return err
}

// $(git commit-tree $(git write-tree) -p HEAD -m "zCLI tmp")
tree, err := h.runCommand(ctx, "git", "write-tree")
if err != nil {
Expand All @@ -46,11 +57,11 @@ func (h *gitArchiver) initialize(ctx context.Context) error {
}
h.commit = commit
case WorkspaceAll:
h.indexFile = path.Join(".git", "zcli-tmp-index-"+uuid.New().String())
h.env = append(h.env, "GIT_INDEX_FILE="+h.indexFile)
indexFile := filepath.Join(h.tmpDir, "index")
h.env = append(h.env, "GIT_INDEX_FILE="+indexFile)

if h.verbose {
h.logger.Info("Using git index file: " + h.indexFile)
h.logger.Info("Using git index file: " + indexFile)
}

if _, err := h.runCommand(ctx, "git", "read-tree", "HEAD"); err != nil {
Expand Down Expand Up @@ -83,23 +94,97 @@ func (h *gitArchiver) initialize(ctx context.Context) error {
return nil
}

func (h *gitArchiver) cleanup(ctx context.Context) {
switch h.workspaceState {
case WorkspaceClean:
return
case WorkspaceStaged:
_ = h.getCommand(ctx, "git", "prune").Run()
case WorkspaceAll:
_ = h.getCommand(ctx, "git", "read-tree", "HEAD").Run()
_ = h.getCommand(ctx, "git", "prune").Run()
// setupQuarantine redirects every object git writes during the push into a throwaway directory, the same way git quarantines incoming objects in receive-pack.
// Reads still fall through to the repository's own object store via alternates, so the temporary tree and commits can be archived.
// No object is ever created in or removed from the user's repository this way (git may still freshen mtimes of objects it finds there), and there is nothing to prune afterwards.
// Pruning used to be the cleanup here and it deleted objects the user had staged but not yet committed, because `git prune` only sees the index it is pointed at.
// Trade-off: in a partial clone, blobs fetched on demand while archiving land in the quarantine too and are fetched again on the next push.
func (h *gitArchiver) setupQuarantine(ctx context.Context) error {
objectsDir, err := h.runCommand(ctx, "git", "rev-parse", "--git-path", "objects")
if err != nil {
return err
}
// Older git prints the path relative to the working directory (`--path-format=absolute` needs git 2.31).
if !filepath.IsAbs(objectsDir) {
objectsDir = filepath.Join(h.workingDir, objectsDir)
}
objectsDir, err = filepath.Abs(objectsDir)
if err != nil {
return err
}

if h.indexFile != "" {
if h.verbose {
h.logger.Info("Removing git index file: " + h.indexFile)
}
_ = os.Remove(path.Join(h.workingDir, h.indexFile))
tmpDir, err := os.MkdirTemp("", "zcli-git-")
if err != nil {
return err
}
h.tmpDir = tmpDir

quarantineDir := filepath.Join(tmpDir, "objects")
if err := os.Mkdir(quarantineDir, 0o700); err != nil {
return err
}

// Keep alternates the caller already had, e.g. when zcli runs from a git hook.
alternates := objectsDir
if existing := os.Getenv("GIT_ALTERNATE_OBJECT_DIRECTORIES"); existing != "" {
alternates += string(os.PathListSeparator) + existing
}
h.env = append(
h.env,
"GIT_OBJECT_DIRECTORY="+quarantineDir,
"GIT_ALTERNATE_OBJECT_DIRECTORIES="+alternates,
)

if h.verbose {
h.logger.Info("Using git object quarantine: " + quarantineDir + " (alternates: " + alternates + ")")
}
return nil
}

// copyIndex points GIT_INDEX_FILE at a copy of the repository's index inside the quarantine directory. A missing index is left missing, git then treats it as empty, exactly as it would the real one.
func (h *gitArchiver) copyIndex(ctx context.Context) error {
indexPath, err := h.runCommand(ctx, "git", "rev-parse", "--git-path", "index")
if err != nil {
return err
}
if !filepath.IsAbs(indexPath) {
indexPath = filepath.Join(h.workingDir, indexPath)
}

indexCopy := filepath.Join(h.tmpDir, "index")
h.env = append(h.env, "GIT_INDEX_FILE="+indexCopy)
if h.verbose {
h.logger.Info("Using git index file: " + indexCopy + " (copy of " + indexPath + ")")
}

src, err := os.Open(indexPath)
if os.IsNotExist(err) {
return nil
}
if err != nil {
return err
}
defer src.Close()

dst, err := os.OpenFile(indexCopy, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
if err != nil {
return err
}
if _, err := io.Copy(dst, src); err != nil {
_ = dst.Close()
return err
}
return dst.Close()
}

func (h *gitArchiver) cleanup() {
if h.tmpDir == "" {
return
}
if h.verbose {
h.logger.Info("Removing git quarantine dir: " + h.tmpDir)
}
_ = os.RemoveAll(h.tmpDir)
}

func (h *gitArchiver) getArchiveCmd(ctx context.Context) *cmdRunner.ExecCmd {
Expand Down
98 changes: 98 additions & 0 deletions src/archiveClient/handler_gitArchiver_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
package archiveClient

import (
"context"
"os"
"os/exec"
"path/filepath"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/zeropsio/zcli/src/logger"
)

func gitArchiverTestRepo(t *testing.T) string {
t.Helper()
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not on PATH")
}
dir := t.TempDir()
run := func(args ...string) {
cmd := exec.CommandContext(t.Context(), "git", args...)
cmd.Dir = dir
cmd.Env = append(os.Environ(), "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null")
out, err := cmd.CombinedOutput()
require.NoErrorf(t, err, "git %v: %s", args, out)
}
require.NoError(t, os.WriteFile(filepath.Join(dir, "a.txt"), []byte("a"), 0o600))
run("init", "-q", "-b", "main")
run("config", "user.email", "t@example.com")
run("config", "user.name", "t")
run("config", "commit.gpgsign", "false")
run("add", "-A")
run("commit", "-q", "-m", "init")
require.NoError(t, os.WriteFile(filepath.Join(dir, "b.txt"), []byte("b"), 0o600))
return dir
}

func countFiles(t *testing.T, dir string) int {
t.Helper()
n := 0
require.NoError(t, filepath.WalkDir(dir, func(_ string, d os.DirEntry, err error) error {
if err != nil {
return err
}
if !d.IsDir() {
n++
}
return nil
}))
return n
}

// Every object the archiver creates lands in the quarantine directory, and cleanup removes it entirely.
func TestGitArchiver_QuarantineLifecycle(t *testing.T) {
for _, state := range []string{WorkspaceAll, WorkspaceStaged, WorkspaceClean} {
t.Run(state, func(t *testing.T) {
dir := gitArchiverTestRepo(t)
objectsBefore := countFiles(t, filepath.Join(dir, ".git", "objects"))

h := newGitArchiver(dir, state, false, logger.NewDebugFileLogger(logger.DebugFileConfig{}))
require.NoError(t, h.initialize(t.Context()))
require.DirExists(t, h.tmpDir)
require.NotEmpty(t, h.commit)

quarantined := countFiles(t, filepath.Join(h.tmpDir, "objects"))
if state == WorkspaceClean {
assert.Equal(t, 0, quarantined)
} else {
assert.Positive(t, quarantined, "temporary tree/commit objects must be written to the quarantine")
}
assert.Equal(t, objectsBefore, countFiles(t, filepath.Join(dir, ".git", "objects")), "no objects may be written into the repo")

h.cleanup()
assert.NoDirExists(t, h.tmpDir)
})
}
}

// A context cancelled before initialize (Ctrl+C) must fail fast and leave no temp directory behind.
func TestGitArchiver_CancelledContextLeavesNothing(t *testing.T) {
dir := gitArchiverTestRepo(t)
ctx, cancel := context.WithCancel(t.Context())
cancel()

h := newGitArchiver(dir, WorkspaceAll, false, logger.NewDebugFileLogger(logger.DebugFileConfig{}))
require.Error(t, h.initialize(ctx))
h.cleanup()
if h.tmpDir != "" {
assert.NoDirExists(t, h.tmpDir)
}
}

// cleanup on an archiver that never initialized is a no-op (initialize may fail before the temp dir exists).
func TestGitArchiver_CleanupWithoutInitialize(t *testing.T) {
h := newGitArchiver(t.TempDir(), WorkspaceAll, false, logger.NewDebugFileLogger(logger.DebugFileConfig{}))
h.cleanup()
}
Loading
Loading