315 lines
11 KiB
Go
315 lines
11 KiB
Go
package worktree
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"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 {
|
|
return m.usesGitCryptContext(context.Background(), repoPath)
|
|
}
|
|
|
|
func (m *Manager) usesGitCryptContext(ctx context.Context, 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
|
|
}
|
|
}
|
|
|
|
// Managed caches have no working tree; read attributes through scoped Git.
|
|
inspectCtx, cancel := context.WithTimeout(ctx, m.inspectTimeout)
|
|
defer cancel()
|
|
cmd := m.newNonInteractiveGitCmd(inspectCtx, repoPath, "show", "HEAD:.gitattributes")
|
|
output, err := runGitCmdCombinedOutput(inspectCtx, cmd)
|
|
return err == nil && strings.Contains(string(output), "filter=git-crypt")
|
|
}
|
|
|
|
// 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}
|
|
}
|
|
}
|
|
|
|
if hasSparseCheckout(ctx) {
|
|
if err := applySparseCheckout(ctx, worktreePath); err != nil {
|
|
return err
|
|
}
|
|
m.initSubmodules(ctx, worktreePath)
|
|
return nil
|
|
}
|
|
|
|
// 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 := newGitCommand(ctx, checkoutArgs...)
|
|
checkoutCmd.Dir = worktreePath
|
|
if output, err := runGitCmdCombinedOutput(ctx, checkoutCmd); 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 := newGitCommand(ctx, "reset", "HEAD", "--", sp)
|
|
resetCmd.Dir = worktreePath
|
|
if output, err := runGitCmdCombinedOutput(ctx, resetCmd); 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 := newGitCommand(ctx, "rev-parse", flag)
|
|
cmd.Dir = worktreePath
|
|
out, err := runGitCmdOutput(ctx, cmd)
|
|
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 := newGitCommand(ctx, "config", "extensions.worktreeConfig", "true")
|
|
cmd.Dir = worktreePath
|
|
if out, err := runGitCmdCombinedOutput(ctx, cmd); 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 := newGitCommand(ctx, "config", "--worktree", kv[0], kv[1])
|
|
cmd.Dir = worktreePath
|
|
if out, err := runGitCmdCombinedOutput(ctx, cmd); 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 := newGitCommand(ctx, "config", kv[0], kv[1])
|
|
cmd.Dir = worktreePath
|
|
if out, err := runGitCmdCombinedOutput(ctx, cmd); 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
|
|
}
|