[object Object]

← back to Cli Printing Press

fix(cli): strip embedded .git/ and .gitmodules in pipeline.CopyDir (#1358)

0f8dc989616797be1c1a326dc6d420dc8426289d · 2026-05-13 20:17:14 -0700 · Trevin Chow

A stray .git/ in the working CLI dir (from `git init` per codex-mode
snapshots, or contributor experimentation) was carried verbatim through
`lock promote` into the library and through `publish package` into the
staging tree. When `git add library/<api>/` later ran in the publish
repo, the staged CLI registered as a submodule pointer (`Am`) instead of
regular files, so the PR shipped with no actual file diff.

Fix at the choke point: pipeline.CopyDir now drops any `.git` entry
(directory, file, or symlink) and any `.gitmodules` file, at any depth.
`.gitignore` and `.gitattributes` are legitimate CLI content and stay.
This covers Promote, PublishWorkingCLI, and `publish package` in one
place rather than duplicating a strip step at each caller.

Refs #1304

Files touched

Diff

commit 0f8dc989616797be1c1a326dc6d420dc8426289d
Author: Trevin Chow <trevin@trevinchow.com>
Date:   Wed May 13 20:17:14 2026 -0700

    fix(cli): strip embedded .git/ and .gitmodules in pipeline.CopyDir (#1358)
    
    A stray .git/ in the working CLI dir (from `git init` per codex-mode
    snapshots, or contributor experimentation) was carried verbatim through
    `lock promote` into the library and through `publish package` into the
    staging tree. When `git add library/<api>/` later ran in the publish
    repo, the staged CLI registered as a submodule pointer (`Am`) instead of
    regular files, so the PR shipped with no actual file diff.
    
    Fix at the choke point: pipeline.CopyDir now drops any `.git` entry
    (directory, file, or symlink) and any `.gitmodules` file, at any depth.
    `.gitignore` and `.gitattributes` are legitimate CLI content and stay.
    This covers Promote, PublishWorkingCLI, and `publish package` in one
    place rather than duplicating a strip step at each caller.
    
    Refs #1304
---
 internal/pipeline/publish.go      | 15 ++++++++
 internal/pipeline/publish_test.go | 72 +++++++++++++++++++++++++++++++++++++++
 2 files changed, 87 insertions(+)

diff --git a/internal/pipeline/publish.go b/internal/pipeline/publish.go
index dc631f50..c9baf52c 100644
--- a/internal/pipeline/publish.go
+++ b/internal/pipeline/publish.go
@@ -553,6 +553,21 @@ func CopyDir(src, dst string) error {
 			return nil
 		}
 
+		// Drop git plumbing wherever it appears in the tree. A stray .git
+		// from `git init` in a working CLI dir, or a nested submodule, would
+		// otherwise be carried into the library and re-staged downstream as
+		// a submodule pointer when `git add` runs in the publish repo.
+		// .gitignore / .gitattributes are legitimate CLI content and stay.
+		if d.Name() == ".git" {
+			if d.IsDir() {
+				return filepath.SkipDir
+			}
+			return nil
+		}
+		if d.Name() == ".gitmodules" {
+			return nil
+		}
+
 		rel, err := filepath.Rel(src, path)
 		if err != nil {
 			return err
diff --git a/internal/pipeline/publish_test.go b/internal/pipeline/publish_test.go
index 5fe4bf8d..5a6407af 100644
--- a/internal/pipeline/publish_test.go
+++ b/internal/pipeline/publish_test.go
@@ -80,6 +80,78 @@ func TestCopyDir(t *testing.T) {
 	}
 }
 
+func TestCopyDirStripsGitPlumbing(t *testing.T) {
+	src := filepath.Join(t.TempDir(), "src")
+	dst := filepath.Join(t.TempDir(), "dst")
+	require.NoError(t, os.MkdirAll(src, 0o755))
+
+	// Top-level .git/ from a stray `git init` in a working CLI dir.
+	require.NoError(t, os.MkdirAll(filepath.Join(src, ".git", "objects", "pack"), 0o755))
+	require.NoError(t, os.WriteFile(filepath.Join(src, ".git", "HEAD"), []byte("ref: refs/heads/main\n"), 0o644))
+	require.NoError(t, os.WriteFile(filepath.Join(src, ".git", "objects", "pack", "pack-abc.idx"), []byte("idx"), 0o644))
+
+	// Nested .git/ (submodule).
+	require.NoError(t, os.MkdirAll(filepath.Join(src, "vendor", "submod", ".git"), 0o755))
+	require.NoError(t, os.WriteFile(filepath.Join(src, "vendor", "submod", ".git", "HEAD"), []byte("ref: refs/heads/main\n"), 0o644))
+	require.NoError(t, os.WriteFile(filepath.Join(src, "vendor", "submod", "module.go"), []byte("package submod"), 0o644))
+
+	// .gitmodules at the root.
+	require.NoError(t, os.WriteFile(filepath.Join(src, ".gitmodules"), []byte("[submodule \"submod\"]\n\tpath = vendor/submod\n"), 0o644))
+
+	// Legitimate CLI content that must be preserved.
+	require.NoError(t, os.WriteFile(filepath.Join(src, ".gitignore"), []byte("/build\n"), 0o644))
+	require.NoError(t, os.WriteFile(filepath.Join(src, ".gitattributes"), []byte("* text=auto\n"), 0o644))
+	require.NoError(t, os.WriteFile(filepath.Join(src, "main.go"), []byte("package main"), 0o644))
+
+	require.NoError(t, CopyDir(src, dst))
+
+	assert.NoDirExists(t, filepath.Join(dst, ".git"))
+	assert.NoDirExists(t, filepath.Join(dst, "vendor", "submod", ".git"))
+	assert.NoFileExists(t, filepath.Join(dst, ".gitmodules"))
+
+	assert.FileExists(t, filepath.Join(dst, ".gitignore"))
+	assert.FileExists(t, filepath.Join(dst, ".gitattributes"))
+	assert.FileExists(t, filepath.Join(dst, "main.go"))
+	assert.FileExists(t, filepath.Join(dst, "vendor", "submod", "module.go"))
+}
+
+// A worktree-style ".git" can be a regular file pointing at the parent
+// gitdir, or a symlink pointing outside the source tree. CopyDir would
+// otherwise reject the symlink for escaping the source root, defeating the
+// strip. Both shapes must drop silently like the directory shape.
+func TestCopyDirStripsGitFileAndSymlink(t *testing.T) {
+	t.Run("git as regular file", func(t *testing.T) {
+		src := filepath.Join(t.TempDir(), "src")
+		dst := filepath.Join(t.TempDir(), "dst")
+		require.NoError(t, os.MkdirAll(src, 0o755))
+		require.NoError(t, os.WriteFile(filepath.Join(src, ".git"), []byte("gitdir: /elsewhere\n"), 0o644))
+		require.NoError(t, os.WriteFile(filepath.Join(src, "main.go"), []byte("package main"), 0o644))
+
+		require.NoError(t, CopyDir(src, dst))
+
+		assert.NoFileExists(t, filepath.Join(dst, ".git"))
+		assert.FileExists(t, filepath.Join(dst, "main.go"))
+	})
+
+	t.Run("git as external symlink", func(t *testing.T) {
+		root := t.TempDir()
+		src := filepath.Join(root, "src")
+		dst := filepath.Join(root, "dst")
+		require.NoError(t, os.MkdirAll(src, 0o755))
+
+		outside := filepath.Join(root, "real-gitdir")
+		require.NoError(t, os.MkdirAll(outside, 0o755))
+		require.NoError(t, os.Symlink(outside, filepath.Join(src, ".git")))
+		require.NoError(t, os.WriteFile(filepath.Join(src, "main.go"), []byte("package main"), 0o644))
+
+		require.NoError(t, CopyDir(src, dst))
+
+		_, err := os.Lstat(filepath.Join(dst, ".git"))
+		assert.True(t, os.IsNotExist(err), "external .git symlink should be skipped, not copied or rejected")
+		assert.FileExists(t, filepath.Join(dst, "main.go"))
+	})
+}
+
 func TestCopyDirRejectsExternalSymlinks(t *testing.T) {
 	tests := []struct {
 		name       string

← a0fac288 chore(main): release 4.6.0 (#1261)  ·  back to Cli Printing Press  ·  fix(cli): drop MCP handler pre-marshal that base64-encoded m 1fc5f62b →