Files
DeepSeek-Reasonix/internal/worktree/worktree.go
T
esengine ec866730e0 fix(gitcmd): host git never runs repository-configured programs
Every git invocation the host makes goes through gitcmd, and each one must
leave the programs a repository's own configuration names unexecuted:
filter and merge drivers, textconv and external diff, fsmonitor, hooks,
signature verifiers, submodule-boundary programs and lazy-fetch transport.
Driver neutralization now covers every subcommand, not diff alone, and takes
driver names from git itself rather than from a separate reading of .git/config.

- Fixed keys are overridden with -c: core.fsmonitor (empty, not "false",
  which git before 2.35.2 reads as a hook name), core.hooksPath,
  log.showSignature, merge.verifySignatures, diff.submodule, gc.auto and
  maintenance.auto and safe.bareRepository=explicit; submodule.recurse=false
  for checkout, switch, restore, reset, merge and read-tree (host branch
  switches and merges never update submodules, whatever the user's global
  submodule.recurse), and elsewhere only where the repository's own config
  sets it.
  GIT_NO_LAZY_FETCH=1 stops a missing object becoming a fetch through the
  repository's remote (git 2.44+); a historical patch that fails for that
  reason returns gitcmd.ErrObjectNotLocal, decided by git's own
  --missing=print listing.
- diff, log and show carry --no-ext-diff and --no-textconv; status and diff
  carry --ignore-submodules=dirty. The subcommand is found past global
  options, so a call passing -C inside args is hardened too.
- Filter and merge drivers defined at local or worktree scope are listed by
  git itself before each invocation (includes followed) and overridden empty;
  user and system scope stay live; a name -c cannot address fails closed
  (ErrRepositoryDrivers).
- worktree add uses --no-checkout and populates through gitcmd inside the new
  worktree, so a conditional include keyed on the new gitdir or branch is
  seen by the driver listing there rather than run by the add's checkout.
  The reset takes the worktree-mutation timeout, and a failed reset removes
  both the worktree and the branch the add created.
  worktree remove uses --force after the existing hardened clean-tree check,
  so git does not run status inside the linked worktree with its config.
- Launcher ls-remote runs Detached with gitcmd's environment (only PATH is
  taken from the launcher's declared env), requires an https://, ssh:// or
  git:// remote (case-insensitive; http:// and local paths are refused), and
  passes the remote and ref past -- so a leading-dash value is an operand.
- worktree git calls with extra environment keep gitcmd's environment.

- Repository identity is resolved once, when a session opens: boot runs
  gitcmd.Open (rev-parse --absolute-git-dir --git-common-dir --show-toplevel)
  before any command, the controller carries the resulting gitcmd.Repo, and a
  rebuild of the same workspace passes it on. Every later host call runs
  through the Repo with GIT_DIR, GIT_COMMON_DIR and GIT_WORK_TREE set, so a
  nested .git, a commondir file, a rewritten .git file or a damaged HEAD
  written afterwards cannot substitute another repository or its config; a
  damaged HEAD fails closed. Threaded through the TUI status line, cli
  review (resolved once before the review agent runs), the desktop changes
  panel, change detail, history, commit detail, branch list/checkout/create
  and tab branch label (the tab session's identity; a tab with no session
  yet resolves at display), worktree Inspect/Create/RollbackCreate, and the
  whole merge-back flow: the source and worktree identities are recorded in
  the merge metadata at creation (the worktree's resolved right after
  worktree add) in metadata.repo.json beside metadata.json, whose shape
  older builds decode strictly and so is unchanged; every inspect,
  auto-commit, merge and cleanup call runs against them, and a worktree
  without that file resolves them from the roots.
  InspectMerge finds the allocation root from the managed layout instead of
  asking git in the worktree.
- Repo.Verify applies Open's test to a stored identity; the identities read
  from metadata.repo.json are verified before the merge flow uses them.
- Auto-commit staging uses gitcmd Repo.StageAll: gitlinks present as
  directories and untracked nested repositories are excluded with
  :(exclude,literal) pathspecs, because add -A checks a submodule by running
  git inside it under its own config. A gitlink keeps the commit the index
  records; an untracked nested repository leaves the worktree unverifiable
  and the auto-commit refuses. The worktree state token no longer runs git
  inside a changed nested repository.
- Open accepts only a git dir belonging to the work tree that holds the
  directory: the work tree's own .git (sharing no other common dir) or a
  linked worktree's admin dir under <common>/worktrees whose gitdir file
  names that work tree's .git. A .git file or core.worktree pointing at
  another checkout is ErrNotRepository. Submodule checkouts and
  --separate-git-dir repositories opened directly are therefore treated as
  not version-controlled.

Regression tests use real git with payload scripts: the gitcmd config-key
matrix (signatures, lazy fetch, submodule boundary), the worktree call sites
(conditional includes on Create, linked-worktree config on RollbackCreate),
the status readout, the launcher's non-URL remote rejection, identity
pinning after a nested .git, a commondir file and a damaged HEAD (config
origin and filter marker), Open refusing another checkout's git dir (.git
file, core.worktree, foreign admin dir), StageAll with nested repositories
named like pathspec magic or globs, metadata.json read by a strict older
decoder, merge-back without the identity file, StageAll and merge-back auto-commit past a gitlink
with its own filter, merge-back after the worktree's .git file is pointed at
another git dir, the status line and desktop changes panel of a workspace
that later gains its own .git, and a global submodule.recurse=true against
host checkout and reset. The list-then-run race is left to a follow-up that
denies sandboxed writes to .git config and hooks.
2026-09-27 17:35:16 -07:00

446 lines
16 KiB
Go

// Package worktree creates durable, Git-backed workspaces for parallel
// Delivery sessions. Attached worktrees live under Reasonix-managed state,
// never inside the source repository, and are never deleted automatically;
// an exact untouched allocation may be rolled back before it is attached.
package worktree
import (
"bytes"
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"reasonix/internal/gitcmd"
)
const (
gitProbeTimeout = 15 * time.Second
gitWorktreeMutationTimeout = 5 * time.Minute
)
// Availability describes whether a project can be isolated with Git worktree.
type Availability struct {
Available bool `json:"available"`
Reason string `json:"reason,omitempty"`
RepoRoot string `json:"repoRoot,omitempty"`
Branch string `json:"branch,omitempty"`
SourceDirty bool `json:"sourceDirty,omitempty"`
}
// RollbackCreate removes a worktree and its branch only while they still match
// the exact clean result returned by Create. Any user or Git mutation makes the
// rollback fail closed and leaves the workspace available for recovery.
func RollbackCreate(ctx context.Context, result Result) error {
sourceRoot := strings.TrimSpace(result.SourceRoot)
worktreeRoot := strings.TrimSpace(result.WorktreeRoot)
branch := strings.TrimSpace(result.Branch)
head := strings.TrimSpace(result.Head)
if sourceRoot == "" || worktreeRoot == "" || branch == "" || head == "" {
return errors.New("rollback needs the complete created worktree identity")
}
if !strings.HasPrefix(branch, "reasonix/delivery-") {
return fmt.Errorf("refuse to roll back unmanaged branch %q", branch)
}
source, created, err := result.repos(ctx)
if err != nil {
return fmt.Errorf("resolve rollback repositories: %w", err)
}
if _, _, err := runGit(ctx, source, "check-ref-format", "refs/heads/"+branch); err != nil {
return fmt.Errorf("refuse to roll back invalid branch %q", branch)
}
sourceInfo, err := os.Stat(sourceRoot)
if err != nil {
return fmt.Errorf("inspect rollback source: %w", err)
}
if !sourceInfo.IsDir() {
return errors.New("rollback source is not a directory")
}
worktreeInfo, err := os.Stat(worktreeRoot)
if err != nil {
return fmt.Errorf("inspect rollback worktree: %w", err)
}
if !worktreeInfo.IsDir() {
return errors.New("rollback worktree is not a directory")
}
if os.SameFile(sourceInfo, worktreeInfo) {
return errors.New("refuse to remove the source worktree")
}
reportedInfo, err := os.Stat(created.WorkTree)
if err != nil || !os.SameFile(worktreeInfo, reportedInfo) {
return errors.New("rollback target is not the exact created worktree root")
}
if err := verifySameCommonDir(source, created); err != nil {
return err
}
currentBranch, _, err := runGit(ctx, created, "symbolic-ref", "--quiet", "--short", "HEAD")
if err != nil || strings.TrimSpace(currentBranch) != branch {
return fmt.Errorf("rollback worktree branch changed from %q", branch)
}
currentHead, _, err := runGit(ctx, created, "rev-parse", "--verify", "HEAD")
if err != nil || strings.TrimSpace(currentHead) != head {
return errors.New("rollback worktree HEAD changed after creation")
}
status, _, err := runGit(ctx, created, "status", "--porcelain=v1", "--untracked-files=all", "--ignored")
if err != nil {
return fmt.Errorf("inspect rollback worktree changes: %w", err)
}
if strings.TrimSpace(status) != "" {
return errors.New("rollback worktree contains changes; it was preserved")
}
metadataFile, err := verifyRollbackMetadata(sourceRoot, worktreeRoot, branch, head)
if err != nil {
return err
}
// --force after the clean-tree check above: a bare remove runs status
// inside the linked worktree, whose config.worktree the source-root
// listing never saw. The check already confirmed the tree is clean.
if _, stderr, err := runGit(ctx, source, "worktree", "remove", "--force", worktreeRoot); err != nil {
return fmt.Errorf("remove unused worktree: %w%s", err, stderrSuffix(stderr))
}
if _, stderr, err := runGit(ctx, source, "update-ref", "-d", "refs/heads/"+branch, head); err != nil {
return fmt.Errorf("remove unused worktree branch %q: %w%s", branch, err, stderrSuffix(stderr))
}
if err := removeMergeMetadata(metadataFile); err != nil {
return fmt.Errorf("remove unused worktree metadata: %w", err)
}
return nil
}
// verifySameCommonDir compares the common dirs the two identities were pinned
// with; neither is rediscovered from its checkout.
func verifySameCommonDir(source, worktree gitcmd.Repo) error {
sourceInfo, err := os.Stat(source.CommonDir)
if err != nil {
return fmt.Errorf("resolve rollback source repository: %w", err)
}
worktreeInfo, err := os.Stat(worktree.CommonDir)
if err != nil || !os.SameFile(sourceInfo, worktreeInfo) {
return errors.New("rollback source and worktree do not share a Git repository")
}
return nil
}
// Result identifies one newly created isolated Delivery workspace.
type Result struct {
WorkspaceRoot string `json:"workspaceRoot"`
WorktreeRoot string `json:"worktreeRoot"`
SourceRoot string `json:"sourceRoot"`
Branch string `json:"branch"`
Head string `json:"head"`
SourceDirty bool `json:"sourceDirty"`
SourceRepo gitcmd.Repo `json:"-"` // the source's identity, at its root
WorktreeRepo gitcmd.Repo `json:"-"` // resolved as the worktree was added
}
// repos is the pinned source and worktree identities; a Result built without
// them resolves each from its root.
func (r Result) repos(ctx context.Context) (source, worktree gitcmd.Repo, err error) {
source, worktree = r.SourceRepo, r.WorktreeRepo
if !source.Valid() {
if source, err = gitcmd.Open(ctx, r.SourceRoot); err != nil {
return source, worktree, err
}
}
if !worktree.Valid() {
worktree, err = gitcmd.Open(ctx, r.WorktreeRoot)
}
return source.Top(), worktree.Top(), err
}
type inspection struct {
Availability
repo gitcmd.Repo // pinned at the work tree root
head string
prefix string
}
// Inspect checks Git and repository prerequisites without changing state.
// repo is the workspace's identity, resolved when it was opened.
func Inspect(ctx context.Context, repo gitcmd.Repo) Availability {
info, err := inspect(ctx, repo)
if err != nil {
return Availability{Available: false, Reason: err.Error()}
}
return info.Availability
}
// Create makes a new branch and linked worktree at managedRoot, based on the
// source repository's committed HEAD. Uncommitted source changes are reported
// but never copied or modified. When workspaceRoot names a repository
// subdirectory, Result.WorkspaceRoot points at the corresponding subdirectory
// in the new worktree. repo is the workspace's identity, resolved when it opened.
func Create(ctx context.Context, repo gitcmd.Repo, managedRoot string) (Result, error) {
info, err := inspect(ctx, repo)
if err != nil {
return Result{}, err
}
managedRoot = strings.TrimSpace(managedRoot)
if managedRoot == "" {
return Result{}, errors.New("Reasonix worktree storage is unavailable")
}
if err := os.MkdirAll(managedRoot, 0o700); err != nil {
return Result{}, fmt.Errorf("create Reasonix worktree storage: %w", err)
}
repoSum := sha256.Sum256([]byte(info.repo.CommonDir))
repoKey := hex.EncodeToString(repoSum[:8])
repoBase := safePathComponent(filepath.Base(info.RepoRoot))
if repoBase == "" {
repoBase = "repository"
}
for range 5 {
id, randomErr := randomID()
if randomErr != nil {
return Result{}, randomErr
}
branch := fmt.Sprintf("reasonix/delivery-%s-%s", time.Now().Format("20060102-150405"), id)
worktreeRoot := filepath.Join(managedRoot, repoKey, id, repoBase)
if _, statErr := os.Stat(worktreeRoot); statErr == nil {
continue
} else if !os.IsNotExist(statErr) {
return Result{}, fmt.Errorf("inspect worktree destination: %w", statErr)
}
if err := os.MkdirAll(filepath.Dir(worktreeRoot), 0o700); err != nil {
return Result{}, fmt.Errorf("create worktree parent: %w", err)
}
// --no-checkout, then populate through gitcmd inside the new worktree,
// where the reset's driver listing sees an includeIf keyed on that
// worktree's gitdir or new branch (a checkout during add would not).
_, stderr, addErr := runGit(ctx, info.repo, "worktree", "add", "--no-checkout", "-b", branch, worktreeRoot, info.head)
var created gitcmd.Repo
if addErr == nil {
var resetStderr string
var resetErr error
if created, resetStderr, resetErr = populate(ctx, worktreeRoot, info.head); resetErr != nil {
_, _, _ = runGit(ctx, info.repo, "worktree", "remove", "--force", worktreeRoot)
_, _, _ = runGit(ctx, info.repo, "update-ref", "-d", "refs/heads/"+branch, info.head)
return Result{}, fmt.Errorf("populate Git worktree: %w%s", resetErr, stderrSuffix(resetStderr))
}
}
if addErr != nil {
// A random branch collision is retryable. We deliberately leave any
// non-empty partial directory untouched rather than risk deleting user
// data after Git returned an ambiguous failure.
if strings.Contains(strings.ToLower(stderr), "already exists") {
continue
}
return Result{}, fmt.Errorf("create Git worktree: %w%s", addErr, stderrSuffix(stderr))
}
selectedRoot := worktreeRoot
if prefix := filepath.FromSlash(strings.Trim(strings.TrimSpace(info.prefix), "/")); prefix != "" && prefix != "." {
selectedRoot = filepath.Join(worktreeRoot, prefix)
st, statErr := os.Stat(selectedRoot)
if statErr != nil || !st.IsDir() {
return Result{}, fmt.Errorf("created worktree is missing selected project subdirectory %q", prefix)
}
}
result := Result{
WorkspaceRoot: selectedRoot,
WorktreeRoot: worktreeRoot,
SourceRoot: info.RepoRoot,
Branch: branch,
Head: info.head,
SourceDirty: info.SourceDirty,
SourceRepo: info.repo,
WorktreeRepo: created,
}
if err := writeMergeMetadata(result, info.Branch); err != nil {
rollbackErr := RollbackCreate(ctx, result)
if rollbackErr != nil {
return Result{}, fmt.Errorf("publish merge metadata and roll back allocation: %w", errors.Join(err, fmt.Errorf("exact-clean rollback failed and the worktree was preserved: %w", rollbackErr)))
}
return Result{}, err
}
return result, nil
}
return Result{}, errors.New("could not allocate a unique Delivery worktree")
}
// IsManagedPath reports whether path belongs to Reasonix's durable worktree
// storage. It is a lexical UI identity check, not an authorization boundary.
func IsManagedPath(path, managedRoot string) bool {
path = strings.TrimSpace(path)
managedRoot = strings.TrimSpace(managedRoot)
if path == "" || managedRoot == "" {
return false
}
absPath, err := filepath.Abs(path)
if err != nil {
return false
}
absManaged, err := filepath.Abs(managedRoot)
if err != nil {
return false
}
rel, err := filepath.Rel(filepath.Clean(absManaged), filepath.Clean(absPath))
if err != nil || rel == "." || rel == "" {
return false
}
return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
}
func inspect(ctx context.Context, repo gitcmd.Repo) (inspection, error) {
workspaceRoot := strings.TrimSpace(repo.Dir)
if workspaceRoot == "" {
return inspection{}, errors.New("project folder is required")
}
st, err := os.Stat(workspaceRoot)
if err != nil {
return inspection{}, fmt.Errorf("project folder is unavailable: %w", err)
}
if !st.IsDir() {
return inspection{}, errors.New("project path is not a folder")
}
if _, err := exec.LookPath("git"); err != nil {
return inspection{}, errors.New("Git is not installed; Delivery remains safe and will serialize writes in this folder")
}
if !repo.Valid() {
return inspection{}, fmt.Errorf("project folder is not inside a Git repository: %w", gitcmd.ErrNotRepository)
}
top := repo.Top()
head, _, err := runGit(ctx, top, "rev-parse", "--verify", "HEAD")
if err != nil || strings.TrimSpace(head) == "" {
return inspection{}, errors.New("the Git repository needs an initial commit before a worktree can be created")
}
head = strings.TrimSpace(head)
prefix, _, err := runGit(ctx, repo, "rev-parse", "--show-prefix")
if err != nil {
return inspection{}, fmt.Errorf("resolve selected project path inside repository: %w", err)
}
prefix = strings.TrimSpace(prefix)
if prefix != "" {
objectType, _, objectErr := runGit(ctx, top, "cat-file", "-t", head+":"+strings.TrimSuffix(prefix, "/"))
if objectErr != nil || strings.TrimSpace(objectType) != "tree" {
return inspection{}, errors.New("the selected project folder is not present in the committed HEAD; commit it before creating a worktree")
}
}
branch, _, _ := runGit(ctx, top, "symbolic-ref", "--quiet", "--short", "HEAD")
status, _, statusErr := runGit(ctx, top, "status", "--porcelain=v1", "--untracked-files=normal")
if statusErr != nil {
return inspection{}, fmt.Errorf("inspect Git working tree: %w", statusErr)
}
return inspection{
Availability: Availability{
Available: true,
RepoRoot: top.WorkTree,
Branch: strings.TrimSpace(branch),
SourceDirty: strings.TrimSpace(status) != "",
},
repo: top,
head: head,
prefix: prefix,
}, nil
}
// populate checks head out into a worktree the host just added, under the
// identity that worktree resolves to now, before anything else has run in it.
func populate(ctx context.Context, root, head string) (gitcmd.Repo, string, error) {
repo, err := gitcmd.Open(ctx, root)
if err != nil {
return repo, "", err
}
_, stderr, err := runGit(ctx, repo, "reset", "--hard", head)
return repo, stderr, err
}
func runGit(parent context.Context, repo gitcmd.Repo, args ...string) (stdout, stderr string, err error) {
return runGitEnvInput(parent, repo, "", nil, args...)
}
func runGitInput(parent context.Context, repo gitcmd.Repo, input string, args ...string) (stdout, stderr string, err error) {
return runGitEnvInput(parent, repo, input, nil, args...)
}
func runGitEnv(parent context.Context, repo gitcmd.Repo, env []string, args ...string) (stdout, stderr string, err error) {
return runGitEnvInput(parent, repo, "", env, args...)
}
// runGitEnvInput runs git against repo's identity, pinned when the repository
// was opened, never one rediscovered from the directory.
func runGitEnvInput(parent context.Context, repo gitcmd.Repo, input string, env []string, args ...string) (stdout, stderr string, err error) {
if parent == nil {
parent = context.Background()
}
ctx, cancel := context.WithTimeout(parent, gitTimeout(args))
defer cancel()
cmd := repo.Command(ctx, args...)
if len(env) > 0 {
cmd.Env = append(cmd.Env, env...)
}
var outBuf, errBuf bytes.Buffer
if input != "" {
cmd.Stdin = strings.NewReader(input)
}
cmd.Stdout = &outBuf
cmd.Stderr = &errBuf
err = cmd.Run()
if ctx.Err() != nil {
err = ctx.Err()
}
return outBuf.String(), strings.TrimSpace(errBuf.String()), err
}
func gitTimeout(args []string) time.Duration {
if len(args) >= 2 && args[0] == "worktree" && (args[1] == "add" || args[1] == "move" || args[1] == "remove") {
return gitWorktreeMutationTimeout
}
// The reset after worktree add is where the checkout happens.
if len(args) >= 1 && args[0] == "reset" {
return gitWorktreeMutationTimeout
}
return gitProbeTimeout
}
func randomID() (string, error) {
var b [5]byte
if _, err := rand.Read(b[:]); err != nil {
return "", fmt.Errorf("generate worktree id: %w", err)
}
return hex.EncodeToString(b[:]), nil
}
func safePathComponent(name string) string {
name = strings.TrimSpace(name)
name = strings.Map(func(r rune) rune {
switch {
case r < 32:
return '-'
case strings.ContainsRune(`/\\:<>"|?*`, r):
return '-'
default:
return r
}
}, name)
name = strings.Trim(name, ". ")
reserved := strings.ToUpper(strings.SplitN(name, ".", 2)[0])
if reserved == "CON" || reserved == "PRN" || reserved == "AUX" || reserved == "NUL" ||
(len(reserved) == 4 && (strings.HasPrefix(reserved, "COM") || strings.HasPrefix(reserved, "LPT")) && reserved[3] >= '1' && reserved[3] <= '9') {
name = "_" + name
}
return name
}
func stderrSuffix(stderr string) string {
stderr = strings.TrimSpace(stderr)
if stderr == "" {
return ""
}
const max = 500
if len(stderr) > max {
stderr = stderr[:max] + "…"
}
return ": " + stderr
}