bc218dfbbe
* feat: add workspace sources from files panel * fix: preserve worktree identity during recovery * feat: unify workspace source actions * fix: validate recovered workspace repositories * fix: harden workspace source filesystem boundaries * test: split workspace repository chip coverage * fix: restrict repository rescan recovery paths * fix: stabilize workspace source CI checks * test: canonicalize workspace source paths * test: align workspace source container paths * fix: populate direct workspace repository names * test: preserve git fixture rewrites across restarts * test: isolate backend git fixture config * fix: resume remote workspace executions before creation * fix: preserve ssh agent identity during resume * fix: preserve SSH workspace across backend restart * fix: simplify workspace actions trigger * fix: refine add workspace repositories UX * docs: narrow workspace source UX updates * fix: avoid phantom turn on session resume * test(web): normalize workspace source request headers * feat(web): streamline workspace source picker * docs: remove trailing spaces from workspace source plan * fix: prevent resume status from creating phantom turns * fix(web): stabilize workspace source attachment * refactor(web): extract workspace source store contract * fix: avoid phantom turns on idle agent resume * fix: address backend static analysis findings * fix: preserve agentctl readiness status value * fix: preserve resume messages without active turns * fix: resume agents from promoted workspace root * chore: merge origin/main into feature/add-new-repo-folder-d97 * feat(web): explain workspace source consequences * test(websocket): count session activity subscription
134 lines
3.9 KiB
Go
134 lines
3.9 KiB
Go
package worktree
|
|
|
|
import (
|
|
"fmt"
|
|
"hash/fnv"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
// BranchIdentityInput contains the durable branch fields used to plan a
|
|
// repository worktree's stable identity and path slug.
|
|
type BranchIdentityInput struct {
|
|
RepositoryID string
|
|
BaseBranch string
|
|
CheckoutBranch string
|
|
DefaultBranch string
|
|
PRNumber int
|
|
Position int
|
|
}
|
|
|
|
// BranchIdentityPlan is the stable identity and optional path suffix for one
|
|
// BranchIdentityInput at the same index in BuildBranchIdentityPlans' result.
|
|
type BranchIdentityPlan struct {
|
|
IdentitySlug string
|
|
PathSlug string
|
|
}
|
|
|
|
// BuildBranchIdentityPlans derives stable identities for repository branches.
|
|
// The lowest-ranked branch for a repeated repository keeps the legacy flat
|
|
// path; every plan still has an identity for durable worktree reuse.
|
|
func BuildBranchIdentityPlans(inputs []BranchIdentityInput) []BranchIdentityPlan {
|
|
plans := make([]BranchIdentityPlan, len(inputs))
|
|
groups := make(map[string][]int, len(inputs))
|
|
for index, input := range inputs {
|
|
if input.RepositoryID != "" {
|
|
groups[input.RepositoryID] = append(groups[input.RepositoryID], index)
|
|
}
|
|
}
|
|
for repositoryID, group := range groups {
|
|
identities := branchIdentitySlugsForGroup(repositoryID, inputs, group)
|
|
if len(group) == 1 {
|
|
index := group[0]
|
|
plans[index] = BranchIdentityPlan{IdentitySlug: identities[index]}
|
|
continue
|
|
}
|
|
flatIdentity := selectFlatBranchIdentity(inputs, group, identities)
|
|
for _, index := range group {
|
|
pathSlug := identities[index]
|
|
if pathSlug == flatIdentity {
|
|
pathSlug = ""
|
|
}
|
|
plans[index] = BranchIdentityPlan{IdentitySlug: identities[index], PathSlug: pathSlug}
|
|
}
|
|
}
|
|
return plans
|
|
}
|
|
|
|
func branchIdentitySlugsForGroup(repositoryID string, inputs []BranchIdentityInput, group []int) map[int]string {
|
|
raw := make(map[int]string, len(group))
|
|
counts := make(map[string]int, len(group))
|
|
for _, index := range group {
|
|
slug := preferredBranchIdentitySlug(inputs[index])
|
|
if slug == "" {
|
|
slug = "branch-" + branchIdentityHash(repositoryID, inputs[index])
|
|
}
|
|
raw[index] = slug
|
|
counts[slug]++
|
|
}
|
|
identities := make(map[int]string, len(group))
|
|
for _, index := range group {
|
|
slug := raw[index]
|
|
if counts[slug] > 1 {
|
|
slug += "-" + branchIdentityHash(repositoryID, inputs[index])
|
|
}
|
|
slug = SanitizeBranchSlug(slug)
|
|
if slug == "" {
|
|
slug = "branch-" + branchIdentityHash(repositoryID, inputs[index])
|
|
}
|
|
identities[index] = slug
|
|
}
|
|
return identities
|
|
}
|
|
|
|
func preferredBranchIdentitySlug(input BranchIdentityInput) string {
|
|
branch := input.CheckoutBranch
|
|
if branch == "" {
|
|
branch = effectiveBaseBranch(input)
|
|
}
|
|
return SanitizeBranchSlug(branch)
|
|
}
|
|
|
|
func branchIdentityHash(repositoryID string, input BranchIdentityInput) string {
|
|
seed := strings.Join([]string{repositoryID, effectiveBaseBranch(input), input.CheckoutBranch, fmt.Sprintf("%d", input.PRNumber)}, "\x00")
|
|
hash := fnv.New32a()
|
|
_, _ = hash.Write([]byte(seed))
|
|
return fmt.Sprintf("%08x", hash.Sum32())
|
|
}
|
|
|
|
func effectiveBaseBranch(input BranchIdentityInput) string {
|
|
if input.BaseBranch != "" {
|
|
return input.BaseBranch
|
|
}
|
|
return input.DefaultBranch
|
|
}
|
|
|
|
func selectFlatBranchIdentity(inputs []BranchIdentityInput, group []int, identities map[int]string) string {
|
|
candidates := append([]int(nil), group...)
|
|
sort.SliceStable(candidates, func(i, j int) bool {
|
|
left, right := inputs[candidates[i]], inputs[candidates[j]]
|
|
if left.Position != right.Position {
|
|
return left.Position < right.Position
|
|
}
|
|
if leftRank, rightRank := flatBranchRank(left), flatBranchRank(right); leftRank != rightRank {
|
|
return leftRank < rightRank
|
|
}
|
|
return identities[candidates[i]] < identities[candidates[j]]
|
|
})
|
|
return identities[candidates[0]]
|
|
}
|
|
|
|
func flatBranchRank(input BranchIdentityInput) int {
|
|
if input.CheckoutBranch != "" {
|
|
return 3
|
|
}
|
|
baseBranch := effectiveBaseBranch(input)
|
|
if input.DefaultBranch != "" && baseBranch == input.DefaultBranch {
|
|
return 0
|
|
}
|
|
if baseBranch == "main" {
|
|
return 1
|
|
}
|
|
return 2
|
|
}
|