Files
kandev/apps/backend/internal/worktree/gitcrypt.go
T
José Almeida e86b6655a3 fix(worktree): handle submodules in worktree creation (#579)
* fix(worktree): handle submodules in worktree creation

Repos with git submodules failed during worktree creation when git-crypt
was also present. The `git checkout HEAD -- .` command in
unlockGitCryptAndCheckout() tried to process submodule gitlink entries,
but git resolved their git dirs relative to the worktree-specific git
dir instead of the common dir, causing "not a git repository" errors.

- Detect submodule paths via `git ls-tree -r HEAD` (mode 160000)
- Exclude submodule paths from git-crypt checkout using :(exclude) pathspecs
- Initialize submodules after worktree creation via `git submodule update --init`
  (non-fatal — unreachable URLs are logged as warnings)
- Add submodule init to all worktree creation paths (git-crypt and non-git-crypt)

* fix(worktree): address review — recursive submodule init, consistent call sites

- Add --recursive to git submodule update --init for nested submodules
- Call initSubmodules unconditionally in git-crypt path (matching manager.go)

* fix(worktree): restore submodule index entries after git-crypt checkout

The :(exclude) pathspec used to skip submodules during git checkout also
prevents their gitlink entries from being written to the index. This
leaves git status showing them as staged deletions.

After checkout, run git reset HEAD for each excluded submodule path to
copy the gitlink entry from HEAD back into the index.

* test(worktree): add git status assertion to submodule unlock test

Validate that the index reset after :(exclude) checkout leaves no
staged deletions for submodule paths.

* fix(worktree): use literal pathspec for submodule exclusion

Use :(literal,exclude) instead of :(exclude) to prevent glob
metacharacters in submodule paths from being interpreted as patterns.

* fix(worktree): fix initSubmodules docstring to match --recursive flag
2026-04-14 15:59:25 +01:00

299 lines
11 KiB
Go

package worktree
import (
"bufio"
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"go.uber.org/zap"
)
// usesGitCrypt checks if a repository uses git-crypt by looking for
// the git-crypt filter in .gitattributes files.
func (m *Manager) usesGitCrypt(repoPath string) bool {
// Check root .gitattributes
if hasGitCryptFilter(filepath.Join(repoPath, ".gitattributes")) {
return true
}
// Check .git/info/attributes (local gitattributes)
gitDir := filepath.Join(repoPath, ".git")
if info, err := os.Stat(gitDir); err == nil && info.IsDir() {
if hasGitCryptFilter(filepath.Join(gitDir, "info", "attributes")) {
return true
}
}
return false
}
// hasGitCryptFilter checks if a gitattributes file contains the git-crypt filter.
func hasGitCryptFilter(path string) bool {
file, err := os.Open(path)
if err != nil {
return false
}
defer func() { _ = file.Close() }()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := scanner.Text()
// git-crypt uses "filter=git-crypt" in gitattributes
if strings.Contains(line, "filter=git-crypt") {
return true
}
}
return false
}
// unlockGitCryptAndCheckout sets up git-crypt decryption in a worktree created
// with --no-checkout and then checks out the files.
//
// If the main repository is unlocked (has keys), it replicates what git-crypt
// unlock does so the worktree gets decrypted files:
// 1. Symlink the git-crypt key directory from the main repo into the worktree.
// 2. Configure the smudge/clean/diff filters in the worktree's local git config.
// 3. Run git checkout to populate the working tree with decrypted files.
//
// If the main repository is locked (no keys), it skips the git-crypt setup and
// checks out without decryption. Encrypted files will remain as binary blobs but
// the worktree is still usable for non-encrypted files.
func (m *Manager) unlockGitCryptAndCheckout(ctx context.Context, worktreePath string) error {
m.logger.Debug("setting up git-crypt filters in worktree",
zap.String("worktree_path", worktreePath))
// Resolve the worktree's git dir and the common dir (main repo .git).
commonDir, err := resolveGitDir(ctx, worktreePath, "--git-common-dir")
if err != nil {
return &GitCryptError{Op: "resolve-common-dir", Path: worktreePath, Output: "", Err: err}
}
gitDir, err := resolveGitDir(ctx, worktreePath, "--git-dir")
if err != nil {
return &GitCryptError{Op: "resolve-git-dir", Path: worktreePath, Output: "", Err: err}
}
// Check if the main repo has git-crypt unlocked (keys present).
src := filepath.Join(commonDir, "git-crypt")
unlocked := isGitCryptUnlocked(src)
if unlocked {
// Symlink the git-crypt key directory into the worktree git dir.
dst := filepath.Join(gitDir, "git-crypt")
if err := symlinkGitCryptDir(src, dst); err != nil {
return &GitCryptError{Op: "symlink", Path: worktreePath, Output: "", Err: err}
}
// Configure the smudge/clean/diff filters.
if err := configureGitCryptFilters(ctx, worktreePath); err != nil {
return &GitCryptError{Op: "config", Path: worktreePath, Output: "", Err: err}
}
} else {
m.logger.Warn("git-crypt is locked in main repository, checking out without decryption",
zap.String("common_dir", commonDir),
zap.String("worktree_path", worktreePath))
// Override any inherited git-crypt filters with pass-through commands.
// Worktrees inherit filter config from the main repo via GIT_COMMON_DIR.
// Without this, git checkout would try to run the smudge filter and fail
// because there are no keys.
if err := disableGitCryptFilters(ctx, worktreePath); err != nil {
return &GitCryptError{Op: "disable-filters", Path: worktreePath, Output: "", Err: err}
}
}
// Exclude submodule paths from checkout to avoid broken gitlink resolution.
// In worktrees, git resolves submodule git dirs relative to the worktree's
// git dir instead of the common dir, which fails.
checkoutArgs := []string{"checkout", "HEAD", "--", "."}
submodulePaths, subErr := getSubmodulePaths(ctx, worktreePath)
if subErr != nil {
m.logger.Debug("could not detect submodules, proceeding without exclusions",
zap.String("worktree_path", worktreePath), zap.Error(subErr))
}
for _, sp := range submodulePaths {
checkoutArgs = append(checkoutArgs, ":(literal,exclude)"+sp)
}
checkoutCmd := exec.CommandContext(ctx, "git", checkoutArgs...)
checkoutCmd.Dir = worktreePath
if output, err := checkoutCmd.CombinedOutput(); err != nil {
m.logger.Error("git checkout failed after git-crypt setup",
zap.String("worktree_path", worktreePath),
zap.String("output", string(output)),
zap.Error(err))
return &GitCryptError{
Op: "checkout",
Path: worktreePath,
Output: string(output),
Err: err,
}
}
// Restore submodule entries in the index that were excluded from checkout.
// Without this, git status shows them as staged deletions.
for _, sp := range submodulePaths {
resetCmd := exec.CommandContext(ctx, "git", "reset", "HEAD", "--", sp)
resetCmd.Dir = worktreePath
if output, err := resetCmd.CombinedOutput(); err != nil {
m.logger.Debug("failed to reset submodule index entry",
zap.String("path", sp),
zap.String("output", string(output)),
zap.Error(err))
}
}
if unlocked {
m.logger.Info("successfully set up git-crypt and checked out worktree",
zap.String("worktree_path", worktreePath))
} else {
m.logger.Info("checked out worktree without git-crypt decryption (repo is locked)",
zap.String("worktree_path", worktreePath))
}
m.initSubmodules(ctx, worktreePath)
return nil
}
// resolveGitDir runs git rev-parse with the given flag (e.g. --git-dir,
// --git-common-dir) and returns the resolved absolute path.
func resolveGitDir(ctx context.Context, worktreePath, flag string) (string, error) {
cmd := exec.CommandContext(ctx, "git", "rev-parse", flag)
cmd.Dir = worktreePath
out, err := cmd.Output()
if err != nil {
return "", fmt.Errorf("git rev-parse %s: %w", flag, err)
}
resolved := strings.TrimSpace(string(out))
if !filepath.IsAbs(resolved) {
resolved = filepath.Join(worktreePath, resolved)
}
return resolved, nil
}
// symlinkGitCryptDir creates a symlink from src (main repo's .git/git-crypt)
// to dst (worktree's git dir git-crypt). Skips if dst already exists.
// Caller must verify src is valid (via isGitCryptUnlocked) before calling.
func symlinkGitCryptDir(src, dst string) error {
if _, err := os.Stat(src); err != nil {
return fmt.Errorf("git-crypt key dir not found at %s: %w", src, err)
}
// Already set up (e.g. retry path).
if _, err := os.Lstat(dst); err == nil {
return nil
}
return os.Symlink(src, dst)
}
// isGitCryptUnlocked checks whether a git-crypt directory exists and contains
// at least one key file (e.g. keys/default). Returns false if the directory
// doesn't exist, has no keys/ subdir, or the keys/ subdir is empty (locked).
func isGitCryptUnlocked(gitCryptDir string) bool {
keysDir := filepath.Join(gitCryptDir, "keys")
entries, err := os.ReadDir(keysDir)
if err != nil {
return false
}
return len(entries) > 0
}
// enableWorktreeConfig enables the worktreeConfig extension so that
// --worktree flag works with git config in linked worktrees.
func enableWorktreeConfig(ctx context.Context, worktreePath string) error {
cmd := exec.CommandContext(ctx, "git", "config", "extensions.worktreeConfig", "true")
cmd.Dir = worktreePath
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("git config extensions.worktreeConfig: %s: %w", string(out), err)
}
return nil
}
// configureGitCryptFilters sets the smudge/clean/diff filters in the
// worktree's local git config so that git checkout can decrypt files.
// Uses --worktree to write to the worktree-local config, not the shared repo config.
func configureGitCryptFilters(ctx context.Context, worktreePath string) error {
if err := enableWorktreeConfig(ctx, worktreePath); err != nil {
return err
}
configs := [][2]string{
{"filter.git-crypt.smudge", "git-crypt smudge"},
{"filter.git-crypt.clean", "git-crypt clean"},
{"filter.git-crypt.required", "true"},
{"diff.git-crypt.textconv", "git-crypt diff"},
}
for _, kv := range configs {
cmd := exec.CommandContext(ctx, "git", "config", "--worktree", kv[0], kv[1])
cmd.Dir = worktreePath
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("git config %s: %s: %w", kv[0], string(out), err)
}
}
return nil
}
// disableGitCryptFilters overrides any inherited git-crypt filter config
// with pass-through commands (cat). This allows checkout to succeed when
// the repo is locked — encrypted files will be checked out as binary blobs.
// Also overrides diff.git-crypt.textconv to prevent git diff/log/show from
// failing on encrypted files.
//
// Note: Unlike configureGitCryptFilters, this writes to the SHARED repo config
// (not worktree-local). This is intentional: if the user later runs git-crypt
// unlock, it will overwrite these settings and decryption will work. Using
// --worktree would shadow git-crypt's config and break unlock-after-create.
//
// Writing to shared config is safe here because the repo is locked — any existing
// git-crypt filter config is useless without keys anyway. When git-crypt unlock
// runs, it will set proper filter values that enable decryption.
//
// We only override smudge (for checkout) and diff (for git diff/log/show).
// We deliberately do NOT override clean or required — if an agent tries to git add
// an encrypted file, the clean filter will fail and required=true will cause git
// to abort rather than silently committing plaintext.
func disableGitCryptFilters(ctx context.Context, worktreePath string) error {
configs := [][2]string{
{"filter.git-crypt.smudge", "cat"},
{"diff.git-crypt.textconv", "cat"},
}
for _, kv := range configs {
cmd := exec.CommandContext(ctx, "git", "config", kv[0], kv[1])
cmd.Dir = worktreePath
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("git config %s: %s: %w", kv[0], string(out), err)
}
}
return nil
}
// GitCryptError represents an error during git-crypt operations in a worktree.
type GitCryptError struct {
Op string // "unlock" or "checkout"
Path string // worktree path
Output string // command output
Err error // underlying error
}
func (e *GitCryptError) Error() string {
return "git-crypt " + e.Op + " failed in worktree " + e.Path + ": " + e.Output
}
func (e *GitCryptError) Unwrap() error {
return e.Err
}
// isGitCryptSmudgeError checks if a git error is caused by git-crypt smudge filter failure.
// The detection is language-agnostic: git translates its own messages but the
// filter name ("git-crypt smudge") always appears verbatim in the output.
func isGitCryptSmudgeError(output string) bool {
lower := strings.ToLower(output)
// Language-agnostic: look for the filter name which is never translated.
if strings.Contains(lower, "git-crypt") && strings.Contains(lower, "smudge") {
return true
}
return false
}