Files
kandev/apps/backend/internal/common/gitref/gitref.go
T
João Salavisa 6ceda6ae4e fix(local-executor): preserve commits in changes panel after refresh (#834)
* fix(local-executor): preserve commits in changes panel after refresh

For a local executor on a feature branch, the create-task dialog sent
the chip's branch as base_branch with empty checkout_branch. agentctl
then recomputed merge-base(HEAD, origin/<branch>) on every changes-panel
refresh; for a never-pushed branch that collapsed to HEAD and the panel
went empty. Repository creation also pinned default_branch to .git/HEAD,
permanently anchoring the repo's integration ref to whatever feature
branch the user happened to be on at first task-create time.

The frontend now splits the chip into base_branch=default_branch +
checkout_branch=working_branch (skipping the split when fresh-branch is
on, where the chip semantically means "fork from"). The backend probes
origin/HEAD then origin/main → main → master (new common/gitref
package) to pick a repo's integration branch, and resolveRepoInput no
longer falls back to BaseBranch when default_branch is missing.

Covered by 9 unit tests on the helper, 6 Go tests on the probe order,
and 7 e2e tests across plain creation, the fork toggle, and the
validate-path probe.

* fix: address PR review feedback

- gitref: preserve slashes in HEAD-fallback branch names (TrimPrefix
  refs/heads/ instead of Split + last component); branches like
  "feature/my-feature" no longer collapse to "my-feature" and poison
  the stored default_branch.
- gitref: drop the broken packed-refs symref scan in readOriginHEAD;
  the loose refs/remotes/origin/HEAD check + pickFirstExistingBranch
  candidates already cover every realistic clone.
- service.resolveRepoInput: validate repoInput.LocalPath via
  resolveAllowedLocalPath() before probing — closes 8 CodeQL
  path-injection alerts.
- buildRepositoriesPayload: fall through to the legacy non-split shape
  when default_branch is unknown for the row (legacy repos with unset
  default_branch). Synthesizing base_branch=rowBranch was reproducing
  the very bug this PR fixes.

* fix(gitref): explicit traversal guard at I/O boundary for CodeQL

Move the path validation from service.resolveAllowedLocalPath (which
CodeQL's taint analysis doesn't recognize as a sanitizer) into a
guardRepoPath check at gitref.DefaultBranch's entry. The new check
rejects relative paths and any '..' segments before any os.Stat /
os.ReadFile call sees the value, which CodeQL recognizes as a
go/path-injection sanitizer.

The upstream allowlist check stays in place — this is a belt-and-
suspenders second barrier at the actual I/O boundary, and it also
makes the gitref package safe for any future callers (migrations,
tests) without requiring them to thread a Service in.

* fix: address review feedback on hardening + e2e race

- gitref.guardRepoPath: check the RAW input for '..' segments before
  filepath.Clean. Clean resolves '..' away from absolute paths
  ("/allowed/../etc" -> "/etc"), so iterating the cleaned path was a
  silent no-op for the very inputs we wanted to reject. Split on both
  '/' and the OS separator. Updated the test with three traversal
  cases.
- gitref_test.go: switch from strings.EqualFold to strict equality —
  git branch names are case-sensitive, "Main" should not pass for
  "main".
- e2e: replace waitForRequest with waitForResponse in
  local-executor-branch-split.spec.ts. waitForRequest resolves on send
  (before the server has persisted the task), so the followup
  apiClient.getTask call could race the write on slow CI. The two
  tests that need the request body keep access via
  (await waitForResponse).request().

* fix(ws): forward CheckoutBranch in wsUpdateTask

The PR fix added CheckoutBranch forwarding to wsCreateTask but missed
the same conversion loop in wsUpdateTask. Any task-update payload with
a repositories array would silently zero out checkout_branch and
re-introduce the merge-base collapse on refresh — same root cause this
PR fixes, in the update path.

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
2026-05-05 22:05:01 +01:00

208 lines
6.7 KiB
Go

// Package gitref reads git ref state directly from the on-disk .git
// directory, without invoking the git binary. The functions here are kept
// minimal and stdlib-only so they can be imported from both the task service
// and the sqlite migration layer (where adding a service dependency would
// invert the existing layering).
package gitref
import (
"fmt"
"os"
"path/filepath"
"strings"
)
const headFile = "HEAD"
// DefaultBranch returns the repository's *integration* branch — the branch
// that work is meant to merge back into. It is intentionally NOT the current
// HEAD: a developer who runs the dialog while checked out on a feature
// branch must still get "main" (or "master") back, otherwise downstream
// consumers (changes-panel merge-base, executor BaseBranch fallback) anchor
// to the wrong ref.
//
// Resolution order:
// 1. refs/remotes/origin/HEAD when set (the upstream's declared default)
// 2. The first ref that exists from
// {origin/main, origin/master, main, master}
// 3. The current HEAD as a last resort, so brand-new repos with only a
// feature branch still produce a value — callers that care about
// correctness can override.
func DefaultBranch(repoPath string) (string, error) {
safe, err := guardRepoPath(repoPath)
if err != nil {
return "", err
}
gitDir, err := ResolveGitDir(safe)
if err != nil {
return "", err
}
commonDir := ResolveCommonGitDir(gitDir)
if branch := readOriginHEAD(commonDir); branch != "" {
return branch, nil
}
if branch := pickFirstExistingBranch(commonDir); branch != "" {
return branch, nil
}
return readHEADBranchFallback(gitDir)
}
// guardRepoPath rejects relative paths and any path containing `..` segments
// before passing the value into the file-reading helpers below. Callers
// already validate against an allowlist (see service.resolveAllowedLocalPath),
// but CodeQL's go/path-injection taint analysis doesn't trace that as a
// sanitizer, so we re-check here at the I/O boundary. The check is also
// genuinely useful: any caller — migration code, tests, future callers —
// must hand us an absolute, traversal-free path or we refuse to read.
func guardRepoPath(repoPath string) (string, error) {
if repoPath == "" {
return "", fmt.Errorf("repository path is required")
}
if !filepath.IsAbs(repoPath) {
return "", fmt.Errorf("repository path must be absolute")
}
// Check the RAW input for '..' segments before cleaning. filepath.Clean
// resolves traversal away from absolute paths (`/allowed/../etc` →
// `/etc`), so iterating the cleaned path would silently pass attempted
// escapes. Splitting on both separators handles cross-platform inputs
// without depending on the OS-native separator.
for _, part := range strings.FieldsFunc(repoPath, func(r rune) bool {
return r == '/' || r == filepath.Separator
}) {
if part == ".." {
return "", fmt.Errorf("repository path must not contain '..' segments")
}
}
return filepath.Clean(repoPath), nil
}
// ResolveGitDir returns the actual git directory for repoPath, following
// `.git` files (worktree pointers) when present.
func ResolveGitDir(repoPath string) (string, error) {
gitPath := filepath.Join(repoPath, ".git")
info, err := os.Stat(gitPath)
if err != nil {
return "", err
}
if info.IsDir() {
return gitPath, nil
}
content, err := os.ReadFile(gitPath)
if err != nil {
return "", err
}
line := strings.TrimSpace(string(content))
if !strings.HasPrefix(line, "gitdir:") {
return "", fmt.Errorf("invalid gitdir reference")
}
gitDir := strings.TrimSpace(strings.TrimPrefix(line, "gitdir:"))
if filepath.IsAbs(gitDir) {
return gitDir, nil
}
return filepath.Clean(filepath.Join(repoPath, gitDir)), nil
}
// ResolveCommonGitDir returns the shared git dir for a worktree, or gitDir
// itself for a regular repo. Refs (refs/heads/*, refs/remotes/*, packed-refs)
// live under the common dir, not the worktree's gitDir.
func ResolveCommonGitDir(gitDir string) string {
commonFile := filepath.Join(gitDir, "commondir")
content, err := os.ReadFile(commonFile)
if err != nil {
return gitDir
}
commonDir := strings.TrimSpace(string(content))
if commonDir == "" {
return gitDir
}
if filepath.IsAbs(commonDir) {
return filepath.Clean(commonDir)
}
return filepath.Clean(filepath.Join(gitDir, commonDir))
}
func readOriginHEAD(commonDir string) string {
headPath := filepath.Join(commonDir, "refs", "remotes", "origin", headFile)
content, err := os.ReadFile(headPath)
if err != nil {
// origin/HEAD only lives in packed-refs after a `git pack-refs --all`,
// and the on-disk symref format there is gnarly to parse correctly.
// We deliberately skip that case rather than maintain a broken parser:
// pickFirstExistingBranch's origin/main → origin/master → main →
// master fallbacks cover every realistic clone in practice.
return ""
}
return parseSymbolicRefToBranch(strings.TrimSpace(string(content)))
}
func parseSymbolicRefToBranch(line string) string {
ref, ok := strings.CutPrefix(line, "ref: ")
if !ok {
ref = line
}
switch {
case strings.HasPrefix(ref, "refs/remotes/origin/"):
return strings.TrimPrefix(ref, "refs/remotes/origin/")
case strings.HasPrefix(ref, "refs/heads/"):
return strings.TrimPrefix(ref, "refs/heads/")
}
return ""
}
func pickFirstExistingBranch(commonDir string) string {
for _, candidate := range []struct{ ref, branch string }{
{"refs/remotes/origin/main", "main"},
{"refs/remotes/origin/master", "master"},
{"refs/heads/main", "main"},
{"refs/heads/master", "master"},
} {
if refExists(commonDir, candidate.ref) {
return candidate.branch
}
}
return ""
}
func refExists(commonDir, ref string) bool {
if _, err := os.Stat(filepath.Join(commonDir, ref)); err == nil {
return true
}
content, err := os.ReadFile(filepath.Join(commonDir, "packed-refs"))
if err != nil {
return false
}
for _, line := range strings.Split(string(content), "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, "^") {
continue
}
_, after, ok := strings.Cut(line, " ")
if !ok {
continue
}
if after == ref {
return true
}
}
return false
}
func readHEADBranchFallback(gitDir string) (string, error) {
headPath := filepath.Join(gitDir, headFile)
content, err := os.ReadFile(headPath)
if err != nil {
return "", err
}
trimmed := strings.TrimSpace(string(content))
if ref, ok := strings.CutPrefix(trimmed, "ref: "); ok {
// Strip refs/heads/ as a prefix rather than splitting on "/" — branch
// names legally contain slashes (e.g. "feature/my-feature"), so taking
// the last path component would silently corrupt every nested branch.
return strings.TrimPrefix(ref, "refs/heads/"), nil
}
if trimmed != "" {
return headFile, nil
}
return "", fmt.Errorf("unable to determine branch")
}