From 9f1b86cc314ac231a4a731acdf0dc520d6d412a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Pavl=C3=AD=C4=8Dek?= Date: Sat, 10 Oct 2026 22:57:31 +0200 Subject: [PATCH] fix broken .git on push --- CHANGELOG.md | 6 + src/archiveClient/handler_archiveGitFiles.go | 3 +- src/archiveClient/handler_gitArchiver.go | 125 ++++- src/archiveClient/handler_gitArchiver_test.go | 98 ++++ ...ervicePushGitIntegrity_integration_test.go | 426 ++++++++++++++++++ src/cmd/servicePushGit_integration_test.go | 9 +- 6 files changed, 642 insertions(+), 25 deletions(-) create mode 100644 src/archiveClient/handler_gitArchiver_test.go create mode 100644 src/cmd/servicePushGitIntegrity_integration_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 8630ed31..1655ca3b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/archiveClient/handler_archiveGitFiles.go b/src/archiveClient/handler_archiveGitFiles.go index 02a233ee..bcf843cb 100644 --- a/src/archiveClient/handler_archiveGitFiles.go +++ b/src/archiveClient/handler_archiveGitFiles.go @@ -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) diff --git a/src/archiveClient/handler_gitArchiver.go b/src/archiveClient/handler_gitArchiver.go index 8c904555..62e2bfac 100644 --- a/src/archiveClient/handler_gitArchiver.go +++ b/src/archiveClient/handler_gitArchiver.go @@ -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" ) @@ -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 @@ -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 { @@ -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 { @@ -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 { diff --git a/src/archiveClient/handler_gitArchiver_test.go b/src/archiveClient/handler_gitArchiver_test.go new file mode 100644 index 00000000..cc89ae1d --- /dev/null +++ b/src/archiveClient/handler_gitArchiver_test.go @@ -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() +} diff --git a/src/cmd/servicePushGitIntegrity_integration_test.go b/src/cmd/servicePushGitIntegrity_integration_test.go new file mode 100644 index 00000000..2eb5cffc --- /dev/null +++ b/src/cmd/servicePushGitIntegrity_integration_test.go @@ -0,0 +1,426 @@ +package cmd + +import ( + "crypto/sha256" + "encoding/hex" + "io/fs" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/zeropsio/zcli/src/archiveClient" +) + +// These tests guard the invariant that `zcli push` never creates, modifies or deletes a file in the user's git directory. +// Before the git object quarantine was introduced, cleanup ran `git prune` with the temporary index still in GIT_INDEX_FILE, which deleted every blob the user had +// staged but not yet committed and broke the next `git commit` with "invalid object". + +// gitDirSnapshot maps every file under the git dir to a content hash, the index included. +// Callers must run their own `git status` before taking the snapshot, because status opportunistically refreshes the index. +func gitDirSnapshot(t *testing.T, gitDir string) map[string]string { + t.Helper() + snap := map[string]string{} + err := filepath.WalkDir(gitDir, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + rel, err := filepath.Rel(gitDir, path) + if err != nil { + return err + } + if d.IsDir() { + snap[rel+"/"] = "" + return nil + } + data, err := os.ReadFile(path) + if err != nil { + return err + } + sum := sha256.Sum256(data) + snap[rel] = hex.EncodeToString(sum[:]) + return nil + }) + require.NoError(t, err) + return snap +} + +func requireGitDirUnchanged(t *testing.T, before, after map[string]string) { + t.Helper() + var diffs []string + for k, v := range before { + if av, ok := after[k]; !ok { + diffs = append(diffs, "deleted: "+k) + } else if av != v { + diffs = append(diffs, "modified: "+k) + } + } + for k := range after { + if _, ok := before[k]; !ok { + diffs = append(diffs, "added: "+k) + } + } + sort.Strings(diffs) + require.Emptyf(t, diffs, "push must not touch the git dir, but it did:\n%s", strings.Join(diffs, "\n")) +} + +// hasEntry is an exact-name lookup. archiveContains matches on suffix, which confuses staged.txt with modified-staged.txt. +func hasEntry(entries map[string]string, name string) bool { + _, ok := entries[name] + return ok +} + +// countQuarantineDirs looks in os.TempDir(), callers must first point TMPDIR at a private directory so concurrent test packages or a developer's own push cannot skew the count. +func countQuarantineDirs(t *testing.T) int { + t.Helper() + matches, err := filepath.Glob(filepath.Join(os.TempDir(), "zcli-git-*")) + require.NoError(t, err) + return len(matches) +} + +// mixedStateRepo builds a repo with one commit and every kind of pending change: a staged new file, a staged modification, an unstaged modification, +// an untracked file, an ignored file and a tracked file deleted from the working tree. +// It returns the object id of the staged new file's blob, which is the object the old prune used to delete. +func mixedStateRepo(t *testing.T, workDir string) (stagedBlob string) { + t.Helper() + writeZeropsYaml(t, workDir, "demo") + require.NoError(t, os.WriteFile(filepath.Join(workDir, "committed.txt"), []byte("committed"), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(workDir, "modified-staged.txt"), []byte("v1"), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(workDir, "modified-unstaged.txt"), []byte("v1"), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(workDir, "deleted.txt"), []byte("gone"), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(workDir, ".gitignore"), []byte("ignored.txt\n"), 0o600)) + gitInit(t, workDir) + gitAddCommit(t, workDir) + + require.NoError(t, os.WriteFile(filepath.Join(workDir, "staged.txt"), []byte("staged"), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(workDir, "modified-staged.txt"), []byte("v2"), 0o600)) + runGit(t, workDir, "add", "staged.txt", "modified-staged.txt") + require.NoError(t, os.WriteFile(filepath.Join(workDir, "modified-unstaged.txt"), []byte("v2"), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(workDir, "untracked.txt"), []byte("untracked"), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(workDir, "ignored.txt"), []byte("ignored"), 0o600)) + require.NoError(t, os.Remove(filepath.Join(workDir, "deleted.txt"))) + + runGit(t, workDir, "status", "--porcelain") + return runGit(t, workDir, "rev-parse", ":staged.txt") +} + +func TestServicePushCommand_GitArchive_RepoUntouchedAndCommitWorksAfterPush(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not on PATH") + } + + cases := []struct { + state string + present []string + absent []string + // unstagedContent is what modified-unstaged.txt must contain in the archive, empty when the file must be absent + stagedContent, unstagedContent string + }{ + { + state: archiveClient.WorkspaceAll, + present: []string{"committed.txt", "staged.txt", "untracked.txt"}, + absent: []string{"ignored.txt", "deleted.txt"}, + stagedContent: "v2", + unstagedContent: "v2", + }, + { + state: archiveClient.WorkspaceStaged, + present: []string{"committed.txt", "staged.txt", "deleted.txt"}, + absent: []string{"ignored.txt", "untracked.txt"}, + stagedContent: "v2", + unstagedContent: "v1", + }, + { + state: archiveClient.WorkspaceClean, + present: []string{"committed.txt", "deleted.txt"}, + absent: []string{"ignored.txt", "untracked.txt", "staged.txt"}, + stagedContent: "v1", + unstagedContent: "v1", + }, + } + + for _, tc := range cases { + t.Run(tc.state, func(t *testing.T) { + f := newFixture(t) + f.SeedLogin("test-token") + workDir := t.TempDir() + stagedBlob := mixedStateRepo(t, workDir) + gitDir := filepath.Join(workDir, ".git") + + statusBefore := runGit(t, workDir, "status", "--porcelain") + before := gitDirSnapshot(t, gitDir) + t.Setenv("TMPDIR", t.TempDir()) + tmpBefore := countQuarantineDirs(t) + + s := registerPushStubs(t, f, "demo") + res := f.Run( + "service", "push", + "--service-id", pushServiceID, + "--working-dir", workDir, + "--workspace-state", tc.state, + "--disable-logs", + ) + assertPushSuccess(t, res, s, "demo") + + body, _ := s.uploadBody.Load().([]byte) + entries := archiveEntries(t, body) + for _, name := range tc.present { + assert.Truef(t, hasEntry(entries, name), "%s must be in the %s archive; got %v", name, tc.state, keys(entries)) + } + for _, name := range tc.absent { + assert.Falsef(t, hasEntry(entries, name), "%s must not be in the %s archive; got %v", name, tc.state, keys(entries)) + } + assert.Equal(t, tc.stagedContent, entries["modified-staged.txt"]) + assert.Equal(t, tc.unstagedContent, entries["modified-unstaged.txt"]) + + requireGitDirUnchanged(t, before, gitDirSnapshot(t, gitDir)) + assert.Equal(t, statusBefore, runGit(t, workDir, "status", "--porcelain"), "working tree and index state must survive the push") + assert.Equal(t, tmpBefore, countQuarantineDirs(t), "quarantine dir must be removed") + + // The regression itself: the staged blob is still there and committing works. + runGit(t, workDir, "cat-file", "-e", stagedBlob) + assert.Empty(t, runGit(t, workDir, "fsck", "--no-dangling", "--strict")) + runGit(t, workDir, "commit", "-q", "-m", "after push") + assert.Equal(t, "staged", runGit(t, workDir, "show", "HEAD:staged.txt")) + }) + } +} + +// Running push twice in a row with changes in between is the everyday flow. Nothing may accumulate in the repo. +func TestServicePushCommand_GitArchive_RepeatedPushesLeaveNothingBehind(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not on PATH") + } + f := newFixture(t) + f.SeedLogin("test-token") + workDir := t.TempDir() + mixedStateRepo(t, workDir) + before := gitDirSnapshot(t, filepath.Join(workDir, ".git")) + + s := registerPushStubs(t, f, "demo") + for i := range 3 { + require.NoError(t, os.WriteFile(filepath.Join(workDir, "untracked.txt"), []byte(strings.Repeat("x", i+1)), 0o600)) + res := f.Run("service", "push", "--service-id", pushServiceID, "--working-dir", workDir, "--disable-logs") + require.Equalf(t, 0, res.ExitCode, "push #%d failed\n--- stderr ---\n%s", i+1, res.Stderr) + body, _ := s.uploadBody.Load().([]byte) + assert.Equal(t, strings.Repeat("x", i+1), archiveEntries(t, body)["untracked.txt"]) + } + assert.Equal(t, int32(3), s.deployHits.Load()) + requireGitDirUnchanged(t, before, gitDirSnapshot(t, filepath.Join(workDir, ".git"))) +} + +// A clean tree pushed with workspace-state=all falls back to HEAD inside the archiver. That path must not write anything either. +func TestServicePushCommand_GitArchive_WorkspaceAllOnCleanTree(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not on PATH") + } + f := newFixture(t) + f.SeedLogin("test-token") + workDir := t.TempDir() + writeZeropsYaml(t, workDir, "demo") + require.NoError(t, os.WriteFile(filepath.Join(workDir, "main.go"), []byte("package main\n"), 0o600)) + gitInit(t, workDir) + gitAddCommit(t, workDir) + before := gitDirSnapshot(t, filepath.Join(workDir, ".git")) + + s := registerPushStubs(t, f, "demo") + res := f.Run("service", "push", "--service-id", pushServiceID, "--working-dir", workDir, "--disable-logs") + assertPushSuccess(t, res, s, "demo") + + body, _ := s.uploadBody.Load().([]byte) + assert.True(t, archiveContains(archiveEntries(t, body), "main.go")) + requireGitDirUnchanged(t, before, gitDirSnapshot(t, filepath.Join(workDir, ".git"))) + assert.Empty(t, runGit(t, workDir, "status", "--porcelain")) +} + +// --working-dir pointing at a subdirectory of the repo. The archive is scoped to that subtree. +// The temporary index used to be created with a path relative to the subdirectory, which git resolved against the repo root and zcli then failed to remove. +func TestServicePushCommand_GitArchive_SubdirectoryWorkingDir(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not on PATH") + } + for _, state := range []string{archiveClient.WorkspaceAll, archiveClient.WorkspaceStaged, archiveClient.WorkspaceClean} { + t.Run(state, func(t *testing.T) { + f := newFixture(t) + f.SeedLogin("test-token") + root := t.TempDir() + sub := filepath.Join(root, "services", "api") + require.NoError(t, os.MkdirAll(sub, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(root, "root-file.txt"), []byte("root"), 0o600)) + writeZeropsYaml(t, sub, "demo") + require.NoError(t, os.WriteFile(filepath.Join(sub, "main.go"), []byte("package main\n"), 0o600)) + gitInit(t, root) + gitAddCommit(t, root) + require.NoError(t, os.WriteFile(filepath.Join(sub, "staged.txt"), []byte("staged"), 0o600)) + runGit(t, root, "add", "services/api/staged.txt") + require.NoError(t, os.WriteFile(filepath.Join(sub, "untracked.txt"), []byte("untracked"), 0o600)) + stagedBlob := runGit(t, root, "rev-parse", ":services/api/staged.txt") + before := gitDirSnapshot(t, filepath.Join(root, ".git")) + + s := registerPushStubs(t, f, "demo") + res := f.Run( + "service", "push", + "--service-id", pushServiceID, + "--working-dir", sub, + "--workspace-state", state, + "--disable-logs", + ) + assertPushSuccess(t, res, s, "demo") + + body, _ := s.uploadBody.Load().([]byte) + entries := archiveEntries(t, body) + assert.Contains(t, entries, "main.go", "archive is relative to the subdirectory; got %v", keys(entries)) + assert.NotContains(t, entries, "root-file.txt") + assert.NotContains(t, entries, "services/api/main.go") + assert.Equal(t, state != archiveClient.WorkspaceClean, hasEntry(entries, "staged.txt")) + assert.Equal(t, state == archiveClient.WorkspaceAll, hasEntry(entries, "untracked.txt")) + + requireGitDirUnchanged(t, before, gitDirSnapshot(t, filepath.Join(root, ".git"))) + runGit(t, root, "cat-file", "-e", stagedBlob) + runGit(t, root, "commit", "-q", "-m", "after push") + }) + } +} + +// A linked worktree keeps its index in .git/worktrees// while objects live in the main repo's .git/objects. +func TestServicePushCommand_GitArchive_LinkedWorktree(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not on PATH") + } + f := newFixture(t) + f.SeedLogin("test-token") + mainDir := t.TempDir() + writeZeropsYaml(t, mainDir, "demo") + require.NoError(t, os.WriteFile(filepath.Join(mainDir, "main.go"), []byte("package main\n"), 0o600)) + gitInit(t, mainDir) + gitAddCommit(t, mainDir) + + wtDir := filepath.Join(t.TempDir(), "feature") + runGit(t, mainDir, "worktree", "add", "-q", "-b", "feature", wtDir) + require.NoError(t, os.WriteFile(filepath.Join(wtDir, "staged.txt"), []byte("staged"), 0o600)) + runGit(t, wtDir, "add", "staged.txt") + require.NoError(t, os.WriteFile(filepath.Join(wtDir, "untracked.txt"), []byte("untracked"), 0o600)) + stagedBlob := runGit(t, wtDir, "rev-parse", ":staged.txt") + before := gitDirSnapshot(t, filepath.Join(mainDir, ".git")) + + s := registerPushStubs(t, f, "demo") + res := f.Run("service", "push", "--service-id", pushServiceID, "--working-dir", wtDir, "--disable-logs") + assertPushSuccess(t, res, s, "demo") + + body, _ := s.uploadBody.Load().([]byte) + entries := archiveEntries(t, body) + assert.True(t, archiveContains(entries, "main.go")) + assert.True(t, archiveContains(entries, "staged.txt")) + assert.True(t, archiveContains(entries, "untracked.txt")) + + requireGitDirUnchanged(t, before, gitDirSnapshot(t, filepath.Join(mainDir, ".git"))) + runGit(t, wtDir, "cat-file", "-e", stagedBlob) + runGit(t, wtDir, "commit", "-q", "-m", "after push") +} + +// A repo that already borrows objects through .git/objects/info/alternates (git clone --shared) must keep working, since the quarantine itself is implemented with alternates. +func TestServicePushCommand_GitArchive_RepoWithAlternates(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not on PATH") + } + f := newFixture(t) + f.SeedLogin("test-token") + upstream := t.TempDir() + writeZeropsYaml(t, upstream, "demo") + require.NoError(t, os.WriteFile(filepath.Join(upstream, "main.go"), []byte("package main\n"), 0o600)) + gitInit(t, upstream) + gitAddCommit(t, upstream) + + workDir := filepath.Join(t.TempDir(), "shared") + runGit(t, upstream, "clone", "-q", "--shared", upstream, workDir) + require.FileExists(t, filepath.Join(workDir, ".git", "objects", "info", "alternates")) + require.NoError(t, os.WriteFile(filepath.Join(workDir, "untracked.txt"), []byte("untracked"), 0o600)) + before := gitDirSnapshot(t, filepath.Join(workDir, ".git")) + upstreamBefore := gitDirSnapshot(t, filepath.Join(upstream, ".git")) + + s := registerPushStubs(t, f, "demo") + res := f.Run("service", "push", "--service-id", pushServiceID, "--working-dir", workDir, "--disable-logs") + assertPushSuccess(t, res, s, "demo") + + body, _ := s.uploadBody.Load().([]byte) + entries := archiveEntries(t, body) + assert.True(t, archiveContains(entries, "main.go"), "file borrowed via alternates; got %v", keys(entries)) + assert.True(t, archiveContains(entries, "untracked.txt")) + requireGitDirUnchanged(t, before, gitDirSnapshot(t, filepath.Join(workDir, ".git"))) + requireGitDirUnchanged(t, upstreamBefore, gitDirSnapshot(t, filepath.Join(upstream, ".git"))) +} + +// GIT_ALTERNATE_OBJECT_DIRECTORIES already set in the environment (e.g. zcli invoked from a git hook) must be chained, not dropped. +func TestServicePushCommand_GitArchive_PreservesEnvAlternates(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not on PATH") + } + f := newFixture(t) + f.SeedLogin("test-token") + + // The committed file lives only in the donor repo, the working repo reaches it through the env alternate. + donor := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(donor, "main.go"), []byte("package main\n"), 0o600)) + gitInit(t, donor) + gitAddCommit(t, donor) + t.Setenv("GIT_ALTERNATE_OBJECT_DIRECTORIES", filepath.Join(donor, ".git", "objects")) + + workDir := t.TempDir() + gitInit(t, workDir) + runGit(t, workDir, "fetch", "-q", donor, "main") + runGit(t, workDir, "reset", "-q", "--hard", "FETCH_HEAD") + writeZeropsYaml(t, workDir, "demo") + before := gitDirSnapshot(t, filepath.Join(workDir, ".git")) + + s := registerPushStubs(t, f, "demo") + res := f.Run("service", "push", "--service-id", pushServiceID, "--working-dir", workDir, "--disable-logs") + assertPushSuccess(t, res, s, "demo") + + body, _ := s.uploadBody.Load().([]byte) + entries := archiveEntries(t, body) + assert.True(t, archiveContains(entries, "main.go"), "got %v", keys(entries)) + assert.True(t, archiveContains(entries, "zerops.yaml")) + requireGitDirUnchanged(t, before, gitDirSnapshot(t, filepath.Join(workDir, ".git"))) +} + +// --deploy-git-folder tars the real .git directory. It must not pick up any zcli temporary files and must leave the repo untouched. +func TestServicePushCommand_GitArchive_DeployGitFolderHasNoTempFiles(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not on PATH") + } + for _, state := range []string{archiveClient.WorkspaceAll, archiveClient.WorkspaceStaged} { + t.Run(state, func(t *testing.T) { + f := newFixture(t) + f.SeedLogin("test-token") + workDir := t.TempDir() + stagedBlob := mixedStateRepo(t, workDir) + before := gitDirSnapshot(t, filepath.Join(workDir, ".git")) + + s := registerPushStubs(t, f, "demo") + res := f.Run( + "service", "push", + "--service-id", pushServiceID, + "--working-dir", workDir, + "--workspace-state", state, + "--deploy-git-folder", + "--disable-logs", + ) + assertPushSuccess(t, res, s, "demo") + + body, _ := s.uploadBody.Load().([]byte) + entries := archiveEntries(t, body) + assert.True(t, archiveContains(entries, ".git/HEAD")) + assert.True(t, archiveContains(entries, "staged.txt")) + for name := range entries { + assert.NotContainsf(t, name, "zcli", "temporary zcli files leaked into the archive: %s", name) + } + requireGitDirUnchanged(t, before, gitDirSnapshot(t, filepath.Join(workDir, ".git"))) + runGit(t, workDir, "cat-file", "-e", stagedBlob) + runGit(t, workDir, "commit", "-q", "-m", "after push") + }) + } +} diff --git a/src/cmd/servicePushGit_integration_test.go b/src/cmd/servicePushGit_integration_test.go index b5663035..379fa6e4 100644 --- a/src/cmd/servicePushGit_integration_test.go +++ b/src/cmd/servicePushGit_integration_test.go @@ -18,19 +18,20 @@ import ( // --- git fixture helpers -------------------------------------------------- -// runGit invokes git in dir with system/global config disabled. Tests must -// not pick up the developer's ~/.gitconfig. -func runGit(t *testing.T, dir string, args ...string) { +// runGit invokes git in dir with system/global config disabled and returns its trimmed output. Tests must not pick up the developer's ~/.gitconfig. +func runGit(t *testing.T, dir string, args ...string) string { t.Helper() cmd := exec.CommandContext(t.Context(), "git", args...) cmd.Dir = dir - cmd.Env = append(os.Environ(), + cmd.Env = append( + os.Environ(), "GIT_TERMINAL_PROMPT=0", "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null", ) out, err := cmd.CombinedOutput() require.NoErrorf(t, err, "git %v failed: %s", args, out) + return strings.TrimSpace(string(out)) } // gitInit creates an empty repo with deterministic identity/config.