← 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
M internal/pipeline/publish.goM internal/pipeline/publish_test.go
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 →