f99010fae1
CI / lint (push) Failing after 1s
CI / frontend (push) Failing after 1s
CI / scripts (push) Failing after 1s
CI / Go Test (ubuntu-latest) (push) Failing after 0s
CI / frontend-node-25 (push) Failing after 1s
CI / docs (push) Failing after 0s
CI / coverage (push) Failing after 0s
CI / e2e (push) Failing after 0s
Docker / build-and-push (push) Failing after 1s
CI / integration (push) Failing after 4m43s
CI / Go Test (windows-latest) (push) Has been cancelled
CI / Desktop Unit Tests (Windows) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Windows) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Has been cancelled
1084 lines
28 KiB
Go
1084 lines
28 KiB
Go
package db
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/mattn/go-sqlite3"
|
|
"go.kenn.io/agentsview/internal/parser"
|
|
)
|
|
|
|
var ErrWorktreeMappingDuplicate = errors.New("worktree mapping already exists")
|
|
var ErrWorktreeMappingInvalid = errors.New("invalid worktree mapping")
|
|
|
|
const (
|
|
WorktreeMappingLayoutExplicit = "explicit"
|
|
WorktreeMappingLayoutRepoDotWorktrees = "repo_dot_worktrees"
|
|
)
|
|
|
|
type WorktreeProjectMapping struct {
|
|
ID int64 `json:"id"`
|
|
Machine string `json:"machine"`
|
|
PathPrefix string `json:"path_prefix"`
|
|
Layout string `json:"layout"`
|
|
Project string `json:"project"`
|
|
Enabled bool `json:"enabled"`
|
|
CreatedAt string `json:"created_at"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
}
|
|
|
|
type ApplyWorktreeProjectMappingsResult struct {
|
|
MatchedSessions int `json:"matched_sessions"`
|
|
UpdatedSessions int `json:"updated_sessions"`
|
|
}
|
|
|
|
func normalizeWorktreeMapping(
|
|
machine string,
|
|
pathPrefix string,
|
|
layout string,
|
|
project string,
|
|
) (WorktreeProjectMapping, error) {
|
|
machine = strings.TrimSpace(machine)
|
|
if machine == "" {
|
|
return WorktreeProjectMapping{}, fmt.Errorf("%w: machine is required", ErrWorktreeMappingInvalid)
|
|
}
|
|
|
|
pathPrefix = strings.TrimSpace(pathPrefix)
|
|
if pathPrefix == "" {
|
|
return WorktreeProjectMapping{}, fmt.Errorf("%w: path_prefix is required", ErrWorktreeMappingInvalid)
|
|
}
|
|
cleanPrefix := filepath.Clean(pathPrefix)
|
|
if cleanPrefix == "." {
|
|
return WorktreeProjectMapping{}, fmt.Errorf("%w: path_prefix is required", ErrWorktreeMappingInvalid)
|
|
}
|
|
if !isFilesystemRoot(cleanPrefix) {
|
|
cleanPrefix = strings.TrimRight(cleanPrefix, string(filepath.Separator))
|
|
}
|
|
|
|
layout = strings.TrimSpace(layout)
|
|
if layout == "" {
|
|
layout = WorktreeMappingLayoutExplicit
|
|
}
|
|
switch layout {
|
|
case WorktreeMappingLayoutExplicit, WorktreeMappingLayoutRepoDotWorktrees:
|
|
default:
|
|
return WorktreeProjectMapping{}, fmt.Errorf(
|
|
"%w: layout must be %s or %s",
|
|
ErrWorktreeMappingInvalid,
|
|
WorktreeMappingLayoutExplicit,
|
|
WorktreeMappingLayoutRepoDotWorktrees,
|
|
)
|
|
}
|
|
|
|
project = strings.TrimSpace(project)
|
|
if layout == WorktreeMappingLayoutExplicit {
|
|
if project == "" {
|
|
return WorktreeProjectMapping{}, fmt.Errorf(
|
|
"%w: project is required",
|
|
ErrWorktreeMappingInvalid,
|
|
)
|
|
}
|
|
project = parser.NormalizeName(project)
|
|
} else {
|
|
project = ""
|
|
}
|
|
|
|
return WorktreeProjectMapping{
|
|
Machine: machine,
|
|
PathPrefix: cleanPrefix,
|
|
Layout: layout,
|
|
Project: parser.NormalizeName(project),
|
|
}, nil
|
|
}
|
|
|
|
func isFilesystemRoot(path string) bool {
|
|
volume := filepath.VolumeName(path)
|
|
return path == volume+string(filepath.Separator)
|
|
}
|
|
|
|
func worktreePathMatches(prefix string, cwd string) bool {
|
|
cwd = strings.TrimSpace(cwd)
|
|
if cwd == "" {
|
|
return false
|
|
}
|
|
cleanCwd := filepath.Clean(cwd)
|
|
if cleanCwd == prefix {
|
|
return true
|
|
}
|
|
matchPrefix := prefix
|
|
if !isFilesystemRoot(matchPrefix) {
|
|
matchPrefix = strings.TrimRight(matchPrefix, string(filepath.Separator))
|
|
matchPrefix += string(filepath.Separator)
|
|
}
|
|
return strings.HasPrefix(cleanCwd, matchPrefix)
|
|
}
|
|
|
|
func scanWorktreeMapping(rows *sql.Rows) (WorktreeProjectMapping, error) {
|
|
var m WorktreeProjectMapping
|
|
var enabled int
|
|
if err := rows.Scan(
|
|
&m.ID,
|
|
&m.Machine,
|
|
&m.PathPrefix,
|
|
&m.Layout,
|
|
&m.Project,
|
|
&enabled,
|
|
&m.CreatedAt,
|
|
&m.UpdatedAt,
|
|
); err != nil {
|
|
return m, err
|
|
}
|
|
if m.Layout == "" {
|
|
m.Layout = WorktreeMappingLayoutExplicit
|
|
}
|
|
m.Enabled = enabled != 0
|
|
return m, nil
|
|
}
|
|
|
|
func scanWorktreeMappingRow(row rowScanner) (WorktreeProjectMapping, error) {
|
|
var m WorktreeProjectMapping
|
|
var enabled int
|
|
if err := row.Scan(
|
|
&m.ID,
|
|
&m.Machine,
|
|
&m.PathPrefix,
|
|
&m.Layout,
|
|
&m.Project,
|
|
&enabled,
|
|
&m.CreatedAt,
|
|
&m.UpdatedAt,
|
|
); err != nil {
|
|
return m, err
|
|
}
|
|
if m.Layout == "" {
|
|
m.Layout = WorktreeMappingLayoutExplicit
|
|
}
|
|
m.Enabled = enabled != 0
|
|
return m, nil
|
|
}
|
|
|
|
func (db *DB) ListWorktreeProjectMappings(
|
|
ctx context.Context,
|
|
machine string,
|
|
) ([]WorktreeProjectMapping, error) {
|
|
rows, err := db.getReader().QueryContext(ctx, `
|
|
SELECT id, machine, path_prefix, layout, project, enabled, created_at, updated_at
|
|
FROM worktree_project_mappings
|
|
WHERE machine = ?
|
|
ORDER BY path_prefix`, strings.TrimSpace(machine))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("listing worktree mappings: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
mappings := []WorktreeProjectMapping{}
|
|
for rows.Next() {
|
|
m, err := scanWorktreeMapping(rows)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("scanning worktree mapping: %w", err)
|
|
}
|
|
mappings = append(mappings, m)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("iterating worktree mappings: %w", err)
|
|
}
|
|
return mappings, nil
|
|
}
|
|
|
|
func (db *DB) CreateWorktreeProjectMapping(
|
|
ctx context.Context,
|
|
m WorktreeProjectMapping,
|
|
) (WorktreeProjectMapping, error) {
|
|
normalized, err := normalizeWorktreeMapping(
|
|
m.Machine, m.PathPrefix, m.Layout, m.Project,
|
|
)
|
|
if err != nil {
|
|
return WorktreeProjectMapping{}, err
|
|
}
|
|
|
|
enabled := 0
|
|
if m.Enabled {
|
|
enabled = 1
|
|
}
|
|
|
|
db.mu.Lock()
|
|
defer db.mu.Unlock()
|
|
|
|
res, err := db.getWriter().ExecContext(ctx, `
|
|
INSERT INTO worktree_project_mappings (machine, path_prefix, layout, project, enabled)
|
|
VALUES (?, ?, ?, ?, ?)`,
|
|
normalized.Machine,
|
|
normalized.PathPrefix,
|
|
normalized.Layout,
|
|
normalized.Project,
|
|
enabled,
|
|
)
|
|
if err != nil {
|
|
if isSQLiteUniqueConstraint(err) {
|
|
return WorktreeProjectMapping{}, ErrWorktreeMappingDuplicate
|
|
}
|
|
return WorktreeProjectMapping{}, fmt.Errorf("creating worktree mapping: %w", err)
|
|
}
|
|
normalized.ID, _ = res.LastInsertId()
|
|
return db.getWorktreeProjectMappingLocked(ctx, normalized.Machine, normalized.ID)
|
|
}
|
|
|
|
func (db *DB) UpdateWorktreeProjectMapping(
|
|
ctx context.Context,
|
|
machine string,
|
|
id int64,
|
|
patch WorktreeProjectMapping,
|
|
) (WorktreeProjectMapping, error) {
|
|
normalized, err := normalizeWorktreeMapping(machine, patch.PathPrefix, patch.Layout, patch.Project)
|
|
if err != nil {
|
|
return WorktreeProjectMapping{}, err
|
|
}
|
|
|
|
enabled := 0
|
|
if patch.Enabled {
|
|
enabled = 1
|
|
}
|
|
|
|
db.mu.Lock()
|
|
defer db.mu.Unlock()
|
|
|
|
res, err := db.getWriter().ExecContext(ctx, `
|
|
UPDATE worktree_project_mappings
|
|
SET path_prefix = ?,
|
|
layout = ?,
|
|
project = ?,
|
|
enabled = ?,
|
|
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')
|
|
WHERE id = ? AND machine = ?`,
|
|
normalized.PathPrefix,
|
|
normalized.Layout,
|
|
normalized.Project,
|
|
enabled,
|
|
id,
|
|
normalized.Machine,
|
|
)
|
|
if err != nil {
|
|
if isSQLiteUniqueConstraint(err) {
|
|
return WorktreeProjectMapping{}, ErrWorktreeMappingDuplicate
|
|
}
|
|
return WorktreeProjectMapping{}, fmt.Errorf("updating worktree mapping: %w", err)
|
|
}
|
|
changed, _ := res.RowsAffected()
|
|
if changed == 0 {
|
|
return WorktreeProjectMapping{}, sql.ErrNoRows
|
|
}
|
|
return db.getWorktreeProjectMappingLocked(ctx, normalized.Machine, id)
|
|
}
|
|
|
|
func (db *DB) DeleteWorktreeProjectMapping(
|
|
ctx context.Context,
|
|
machine string,
|
|
id int64,
|
|
) error {
|
|
db.mu.Lock()
|
|
defer db.mu.Unlock()
|
|
|
|
res, err := db.getWriter().ExecContext(ctx,
|
|
`DELETE FROM worktree_project_mappings WHERE id = ? AND machine = ?`,
|
|
id,
|
|
strings.TrimSpace(machine),
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("deleting worktree mapping: %w", err)
|
|
}
|
|
changed, _ := res.RowsAffected()
|
|
if changed == 0 {
|
|
return sql.ErrNoRows
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (db *DB) getWorktreeProjectMappingLocked(
|
|
ctx context.Context,
|
|
machine string,
|
|
id int64,
|
|
) (WorktreeProjectMapping, error) {
|
|
row := db.getWriter().QueryRowContext(ctx, `
|
|
SELECT id, machine, path_prefix, layout, project, enabled, created_at, updated_at
|
|
FROM worktree_project_mappings
|
|
WHERE id = ? AND machine = ?`,
|
|
id,
|
|
machine,
|
|
)
|
|
m, err := scanWorktreeMappingRow(row)
|
|
if err != nil {
|
|
return WorktreeProjectMapping{}, err
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (db *DB) ResolveWorktreeProjectMapping(
|
|
ctx context.Context,
|
|
machine string,
|
|
cwd string,
|
|
currentProject string,
|
|
) (string, bool, error) {
|
|
mappings, err := db.activeWorktreeProjectMappings(ctx, machine)
|
|
if err != nil {
|
|
return currentProject, false, err
|
|
}
|
|
project, ok := ResolveWorktreeProjectFromMappings(
|
|
mappings, cwd, currentProject,
|
|
)
|
|
return project, ok, nil
|
|
}
|
|
|
|
// ListActiveWorktreeProjectMappings returns enabled mappings
|
|
// for a machine in resolution order, with the longest path
|
|
// prefixes first.
|
|
func (db *DB) ListActiveWorktreeProjectMappings(
|
|
ctx context.Context,
|
|
machine string,
|
|
) ([]WorktreeProjectMapping, error) {
|
|
return db.activeWorktreeProjectMappings(ctx, machine)
|
|
}
|
|
|
|
// CopyWorktreeProjectMappingsFrom copies persistent worktree mappings from a
|
|
// source DB into this DB. Omit id so source primary keys cannot shadow
|
|
// destination rows; UNIQUE(machine, path_prefix) conflicts preserve existing
|
|
// destination mappings.
|
|
func (db *DB) CopyWorktreeProjectMappingsFrom(sourcePath string) error {
|
|
db.mu.Lock()
|
|
defer db.mu.Unlock()
|
|
|
|
ctx := context.Background()
|
|
conn, err := db.getWriter().Conn(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("acquiring connection: %w", err)
|
|
}
|
|
defer conn.Close()
|
|
|
|
if _, err := conn.ExecContext(
|
|
ctx, "ATTACH DATABASE ? AS old_db", sourcePath,
|
|
); err != nil {
|
|
return fmt.Errorf("attaching source db: %w", err)
|
|
}
|
|
defer func() {
|
|
_, _ = conn.ExecContext(ctx, "DETACH DATABASE old_db")
|
|
}()
|
|
|
|
tx, err := conn.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("begin worktree mapping copy tx: %w", err)
|
|
}
|
|
defer func() { _ = tx.Rollback() }()
|
|
|
|
if oldDBHasTable(ctx, tx, "worktree_project_mappings") {
|
|
layoutSelect := "'" + WorktreeMappingLayoutExplicit + "'"
|
|
if oldDBHasColumn(ctx, tx, "worktree_project_mappings", "layout") {
|
|
layoutSelect = "layout"
|
|
}
|
|
if _, err := tx.ExecContext(ctx, `
|
|
INSERT OR IGNORE INTO main.worktree_project_mappings
|
|
(machine, path_prefix, layout, project, enabled, created_at, updated_at)
|
|
SELECT machine, path_prefix, `+layoutSelect+`, project, enabled, created_at, updated_at
|
|
FROM old_db.worktree_project_mappings`); err != nil {
|
|
return fmt.Errorf("copying worktree project mappings: %w", err)
|
|
}
|
|
}
|
|
|
|
return tx.Commit()
|
|
}
|
|
|
|
// ResolveWorktreeProjectFromMappings applies the same longest
|
|
// prefix worktree mapping semantics as ResolveWorktreeProjectMapping
|
|
// to an already-loaded mapping set. It defensively sorts a copy so
|
|
// callers cannot accidentally depend on input order.
|
|
func ResolveWorktreeProjectFromMappings(
|
|
mappings []WorktreeProjectMapping,
|
|
cwd string,
|
|
currentProject string,
|
|
) (string, bool) {
|
|
mappings = sortedWorktreeProjectMappings(mappings)
|
|
return ResolveWorktreeProjectFromSortedMappings(
|
|
mappings, cwd, currentProject,
|
|
)
|
|
}
|
|
|
|
// ResolveWorktreeProjectFromSortedMappings applies longest-prefix
|
|
// semantics to a mapping set already sorted by descending path prefix
|
|
// length. Use this in hot paths with mappings loaded by
|
|
// ListActiveWorktreeProjectMappings.
|
|
func ResolveWorktreeProjectFromSortedMappings(
|
|
mappings []WorktreeProjectMapping,
|
|
cwd string,
|
|
currentProject string,
|
|
) (string, bool) {
|
|
for _, mapping := range mappings {
|
|
if !worktreePathMatches(mapping.PathPrefix, cwd) {
|
|
continue
|
|
}
|
|
if project, ok := resolveWorktreeProjectFromMapping(
|
|
mapping, cwd, currentProject,
|
|
); ok {
|
|
return project, true
|
|
}
|
|
}
|
|
return currentProject, false
|
|
}
|
|
|
|
func sortedWorktreeProjectMappings(
|
|
mappings []WorktreeProjectMapping,
|
|
) []WorktreeProjectMapping {
|
|
sorted := append([]WorktreeProjectMapping(nil), mappings...)
|
|
sortWorktreeProjectMappings(sorted)
|
|
return sorted
|
|
}
|
|
|
|
func sortWorktreeProjectMappings(mappings []WorktreeProjectMapping) {
|
|
sort.SliceStable(mappings, func(i, j int) bool {
|
|
left := mappings[i].PathPrefix
|
|
right := mappings[j].PathPrefix
|
|
if len(left) != len(right) {
|
|
return len(left) > len(right)
|
|
}
|
|
return left < right
|
|
})
|
|
}
|
|
|
|
func (db *DB) activeWorktreeProjectMappings(
|
|
ctx context.Context,
|
|
machine string,
|
|
) ([]WorktreeProjectMapping, error) {
|
|
rows, err := db.getReader().QueryContext(ctx, `
|
|
SELECT id, machine, path_prefix, layout, project, enabled, created_at, updated_at
|
|
FROM worktree_project_mappings
|
|
WHERE machine = ? AND enabled = 1
|
|
ORDER BY length(path_prefix) DESC, path_prefix`,
|
|
strings.TrimSpace(machine),
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("querying active worktree mappings: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
mappings := []WorktreeProjectMapping{}
|
|
for rows.Next() {
|
|
m, err := scanWorktreeMapping(rows)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("scanning active worktree mapping: %w", err)
|
|
}
|
|
mappings = append(mappings, m)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("iterating active worktree mappings: %w", err)
|
|
}
|
|
return mappings, nil
|
|
}
|
|
|
|
func resolveWorktreeProjectFromMapping(
|
|
mapping WorktreeProjectMapping,
|
|
cwd string,
|
|
currentProject string,
|
|
) (string, bool) {
|
|
switch mapping.Layout {
|
|
case "", WorktreeMappingLayoutExplicit:
|
|
if mapping.Project == "" {
|
|
return currentProject, false
|
|
}
|
|
return mapping.Project, true
|
|
case WorktreeMappingLayoutRepoDotWorktrees:
|
|
project, _, ok := resolveRepoDotWorktrees(mapping.PathPrefix, cwd)
|
|
if !ok {
|
|
return currentProject, false
|
|
}
|
|
return project, true
|
|
default:
|
|
return currentProject, false
|
|
}
|
|
}
|
|
|
|
// resolveRepoDotWorktrees resolves a cwd under a repo_dot_worktrees mapping to
|
|
// its project name and the repo.worktrees directory shared by all branches.
|
|
func resolveRepoDotWorktrees(
|
|
pathPrefix string,
|
|
cwd string,
|
|
) (string, string, bool) {
|
|
cwd = strings.TrimSpace(cwd)
|
|
if cwd == "" {
|
|
return "", "", false
|
|
}
|
|
rel, err := filepath.Rel(pathPrefix, filepath.Clean(cwd))
|
|
if err != nil || rel == "." || rel == "" {
|
|
return "", "", false
|
|
}
|
|
if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
|
return "", "", false
|
|
}
|
|
idx := strings.IndexRune(rel, filepath.Separator)
|
|
if idx < 0 {
|
|
return "", "", false
|
|
}
|
|
first := rel[:idx]
|
|
if !strings.HasSuffix(first, ".worktrees") {
|
|
return "", "", false
|
|
}
|
|
repo := strings.TrimSpace(strings.TrimSuffix(first, ".worktrees"))
|
|
if repo == "" {
|
|
return "", "", false
|
|
}
|
|
return parser.NormalizeName(repo), filepath.Join(pathPrefix, first), true
|
|
}
|
|
|
|
type worktreeMappingSessionRow struct {
|
|
id string
|
|
machine string
|
|
project string
|
|
cwd string
|
|
filePath string
|
|
matchCwd string
|
|
}
|
|
|
|
type worktreeMappingSessionUpdate struct {
|
|
id string
|
|
machine string
|
|
cwd string
|
|
currentProject string
|
|
nextProject string
|
|
}
|
|
|
|
func loadActiveWorktreeMappingsTx(
|
|
ctx context.Context,
|
|
tx *sql.Tx,
|
|
machine string,
|
|
) ([]WorktreeProjectMapping, error) {
|
|
rows, err := tx.QueryContext(ctx, `
|
|
SELECT id, machine, path_prefix, layout, project, enabled, created_at, updated_at
|
|
FROM worktree_project_mappings
|
|
WHERE machine = ? AND enabled = 1
|
|
ORDER BY length(path_prefix) DESC, path_prefix`,
|
|
machine,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("querying active worktree mappings: %w", err)
|
|
}
|
|
return scanWorktreeMappings(rows)
|
|
}
|
|
|
|
func loadActiveWorktreeMappingsByMachineTx(
|
|
ctx context.Context,
|
|
tx *sql.Tx,
|
|
machines map[string]bool,
|
|
) (map[string][]WorktreeProjectMapping, error) {
|
|
rows, err := tx.QueryContext(ctx, `
|
|
SELECT id, machine, path_prefix, layout, project, enabled, created_at, updated_at
|
|
FROM worktree_project_mappings
|
|
WHERE enabled = 1
|
|
ORDER BY machine, length(path_prefix) DESC, path_prefix`,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("querying active worktree mappings: %w", err)
|
|
}
|
|
mappings, err := scanWorktreeMappings(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
mappingsByMachine := map[string][]WorktreeProjectMapping{}
|
|
for _, mapping := range mappings {
|
|
if machines[mapping.Machine] {
|
|
mappingsByMachine[mapping.Machine] = append(
|
|
mappingsByMachine[mapping.Machine],
|
|
mapping,
|
|
)
|
|
}
|
|
}
|
|
return mappingsByMachine, nil
|
|
}
|
|
|
|
func scanWorktreeMappings(rows *sql.Rows) ([]WorktreeProjectMapping, error) {
|
|
defer rows.Close()
|
|
|
|
mappings := []WorktreeProjectMapping{}
|
|
for rows.Next() {
|
|
mapping, err := scanWorktreeMapping(rows)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("scanning active worktree mapping: %w", err)
|
|
}
|
|
mappings = append(mappings, mapping)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("iterating active worktree mappings: %w", err)
|
|
}
|
|
return mappings, nil
|
|
}
|
|
|
|
func applyMappingToSessionRow(
|
|
mappings []WorktreeProjectMapping,
|
|
row worktreeMappingSessionRow,
|
|
) (worktreeMappingSessionUpdate, bool, bool) {
|
|
matchCwd := row.matchCwd
|
|
if matchCwd == "" {
|
|
matchCwd = row.cwd
|
|
}
|
|
project, ok := ResolveWorktreeProjectFromSortedMappings(mappings, matchCwd, row.project)
|
|
if !ok {
|
|
return worktreeMappingSessionUpdate{}, false, false
|
|
}
|
|
if project == row.project {
|
|
return worktreeMappingSessionUpdate{}, true, false
|
|
}
|
|
return worktreeMappingSessionUpdate{
|
|
id: row.id,
|
|
machine: row.machine,
|
|
cwd: row.cwd,
|
|
currentProject: row.project,
|
|
nextProject: project,
|
|
}, true, true
|
|
}
|
|
|
|
func applyWorktreeMappingMatchCwdFromSiblings(
|
|
rows []worktreeMappingSessionRow,
|
|
siblingKey func(worktreeMappingSessionRow) string,
|
|
resolveProject func(row worktreeMappingSessionRow, cwd string) (string, bool),
|
|
) {
|
|
type siblingCandidate struct {
|
|
cwd string
|
|
project string
|
|
}
|
|
candidatesBySibling := map[string][]siblingCandidate{}
|
|
unresolvedBySibling := map[string]bool{}
|
|
for _, row := range rows {
|
|
key := siblingKey(row)
|
|
if key == "" || row.cwd == "" {
|
|
continue
|
|
}
|
|
project, ok := resolveProject(row, row.cwd)
|
|
if !ok {
|
|
unresolvedBySibling[key] = true
|
|
continue
|
|
}
|
|
candidates := candidatesBySibling[key]
|
|
alreadySeen := false
|
|
for _, candidate := range candidates {
|
|
if candidate.project == project {
|
|
alreadySeen = true
|
|
break
|
|
}
|
|
}
|
|
if !alreadySeen {
|
|
candidatesBySibling[key] = append(
|
|
candidates, siblingCandidate{cwd: row.cwd, project: project},
|
|
)
|
|
}
|
|
}
|
|
|
|
// Only fall back when every non-empty sibling resolves to a mapping and
|
|
// all of them agree on the same project; an unmapped sibling or
|
|
// conflicting projects mean the fallback would be a guess.
|
|
fallbackBySibling := map[string]string{}
|
|
for key, candidates := range candidatesBySibling {
|
|
if len(candidates) == 1 && !unresolvedBySibling[key] {
|
|
fallbackBySibling[key] = candidates[0].cwd
|
|
}
|
|
}
|
|
|
|
for i, row := range rows {
|
|
row.matchCwd = row.cwd
|
|
key := siblingKey(row)
|
|
if row.cwd == "" && key != "" {
|
|
row.matchCwd = fallbackBySibling[key]
|
|
}
|
|
rows[i] = row
|
|
}
|
|
}
|
|
|
|
func updateSessionProjectTx(
|
|
ctx context.Context,
|
|
tx *sql.Tx,
|
|
update worktreeMappingSessionUpdate,
|
|
bumpLocalModifiedAt bool,
|
|
) (int, error) {
|
|
updateSQL := `
|
|
UPDATE sessions
|
|
SET project = ?
|
|
WHERE id = ?
|
|
AND machine = ?
|
|
AND deleted_at IS NULL
|
|
AND cwd = ?
|
|
AND project = ?`
|
|
if bumpLocalModifiedAt {
|
|
updateSQL = `
|
|
UPDATE sessions
|
|
SET project = ?,
|
|
local_modified_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')
|
|
WHERE id = ?
|
|
AND machine = ?
|
|
AND deleted_at IS NULL
|
|
AND cwd = ?
|
|
AND project = ?`
|
|
}
|
|
res, err := tx.ExecContext(ctx, updateSQL,
|
|
update.nextProject,
|
|
update.id,
|
|
update.machine,
|
|
update.cwd,
|
|
update.currentProject,
|
|
)
|
|
if err != nil {
|
|
return 0, fmt.Errorf(
|
|
"applying worktree mapping to session %s: %w",
|
|
update.id,
|
|
err,
|
|
)
|
|
}
|
|
changed, _ := res.RowsAffected()
|
|
return int(changed), nil
|
|
}
|
|
|
|
func (db *DB) ApplyWorktreeProjectMappings(
|
|
ctx context.Context,
|
|
machine string,
|
|
) (ApplyWorktreeProjectMappingsResult, error) {
|
|
return db.applyWorktreeProjectMappings(ctx, machine, true)
|
|
}
|
|
|
|
func (db *DB) ApplyWorktreeProjectMappingsFromSync(
|
|
ctx context.Context,
|
|
machine string,
|
|
) (ApplyWorktreeProjectMappingsResult, error) {
|
|
return db.applyWorktreeProjectMappings(ctx, machine, false)
|
|
}
|
|
|
|
func (db *DB) applyWorktreeProjectMappings(
|
|
ctx context.Context,
|
|
machine string,
|
|
bumpLocalModifiedAt bool,
|
|
) (ApplyWorktreeProjectMappingsResult, error) {
|
|
machine = strings.TrimSpace(machine)
|
|
|
|
db.mu.Lock()
|
|
defer db.mu.Unlock()
|
|
|
|
tx, err := db.getWriter().BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return ApplyWorktreeProjectMappingsResult{}, fmt.Errorf(
|
|
"beginning worktree mapping apply: %w", err,
|
|
)
|
|
}
|
|
defer func() { _ = tx.Rollback() }()
|
|
|
|
mappings, err := loadActiveWorktreeMappingsTx(ctx, tx, machine)
|
|
if err != nil {
|
|
return ApplyWorktreeProjectMappingsResult{}, fmt.Errorf(
|
|
"loading active worktree mappings: %w", err,
|
|
)
|
|
}
|
|
|
|
rows, err := tx.QueryContext(ctx, `
|
|
SELECT id, project, cwd, file_path
|
|
FROM sessions
|
|
WHERE machine = ? AND deleted_at IS NULL`,
|
|
machine,
|
|
)
|
|
if err != nil {
|
|
return ApplyWorktreeProjectMappingsResult{}, fmt.Errorf(
|
|
"querying sessions for worktree mapping apply: %w", err,
|
|
)
|
|
}
|
|
|
|
rowsForMachine := []worktreeMappingSessionRow{}
|
|
var updates []worktreeMappingSessionUpdate
|
|
var result ApplyWorktreeProjectMappingsResult
|
|
for rows.Next() {
|
|
var row worktreeMappingSessionRow
|
|
var filePath sql.NullString
|
|
row.machine = machine
|
|
if err := rows.Scan(&row.id, &row.project, &row.cwd, &filePath); err != nil {
|
|
rows.Close()
|
|
return result, fmt.Errorf("scanning session for worktree mapping apply: %w", err)
|
|
}
|
|
if filePath.Valid {
|
|
row.filePath = filePath.String
|
|
}
|
|
rowsForMachine = append(rowsForMachine, row)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
rows.Close()
|
|
return result, fmt.Errorf("iterating sessions for worktree mapping apply: %w", err)
|
|
}
|
|
if err := rows.Close(); err != nil {
|
|
return result, fmt.Errorf("closing worktree mapping apply rows: %w", err)
|
|
}
|
|
|
|
applyWorktreeMappingMatchCwdFromSiblings(rowsForMachine, func(row worktreeMappingSessionRow) string {
|
|
return strings.TrimSpace(row.filePath)
|
|
}, func(row worktreeMappingSessionRow, cwd string) (string, bool) {
|
|
return ResolveWorktreeProjectFromSortedMappings(mappings, cwd, row.project)
|
|
})
|
|
|
|
for _, row := range rowsForMachine {
|
|
update, matched, shouldUpdate := applyMappingToSessionRow(mappings, row)
|
|
if !matched {
|
|
continue
|
|
}
|
|
result.MatchedSessions++
|
|
if shouldUpdate {
|
|
updates = append(updates, update)
|
|
}
|
|
}
|
|
|
|
for _, update := range updates {
|
|
changed, err := updateSessionProjectTx(
|
|
ctx, tx, update, bumpLocalModifiedAt,
|
|
)
|
|
if err != nil {
|
|
return result, err
|
|
}
|
|
result.UpdatedSessions += changed
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return result, fmt.Errorf("committing worktree mapping apply: %w", err)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (db *DB) ApplyWorktreeProjectMappingToSession(
|
|
ctx context.Context,
|
|
machine string,
|
|
sessionID string,
|
|
cwd string,
|
|
currentProject string,
|
|
) (bool, error) {
|
|
return db.applyWorktreeProjectMappingToSession(
|
|
ctx, machine, sessionID, cwd, currentProject, true,
|
|
)
|
|
}
|
|
|
|
func (db *DB) ApplyWorktreeProjectMappingToSessionFromSync(
|
|
ctx context.Context,
|
|
machine string,
|
|
sessionID string,
|
|
cwd string,
|
|
currentProject string,
|
|
) (bool, error) {
|
|
return db.applyWorktreeProjectMappingToSession(
|
|
ctx, machine, sessionID, cwd, currentProject, false,
|
|
)
|
|
}
|
|
|
|
func (db *DB) applyWorktreeProjectMappingToSession(
|
|
ctx context.Context,
|
|
machine string,
|
|
sessionID string,
|
|
cwd string,
|
|
currentProject string,
|
|
bumpLocalModifiedAt bool,
|
|
) (bool, error) {
|
|
if err := db.requireWritable(); err != nil {
|
|
return false, err
|
|
}
|
|
machine = strings.TrimSpace(machine)
|
|
sessionID = strings.TrimSpace(sessionID)
|
|
if machine == "" || sessionID == "" || strings.TrimSpace(cwd) == "" {
|
|
return false, nil
|
|
}
|
|
|
|
db.mu.Lock()
|
|
defer db.mu.Unlock()
|
|
|
|
tx, err := db.getWriter().BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return false, fmt.Errorf(
|
|
"beginning worktree mapping session apply: %w", err,
|
|
)
|
|
}
|
|
defer func() { _ = tx.Rollback() }()
|
|
|
|
mappings, err := loadActiveWorktreeMappingsTx(ctx, tx, machine)
|
|
if err != nil {
|
|
return false, fmt.Errorf("loading active worktree mappings: %w", err)
|
|
}
|
|
|
|
row := worktreeMappingSessionRow{
|
|
id: sessionID,
|
|
machine: machine,
|
|
}
|
|
err = tx.QueryRowContext(ctx, `
|
|
SELECT project, cwd
|
|
FROM sessions
|
|
WHERE id = ? AND machine = ? AND deleted_at IS NULL`,
|
|
sessionID,
|
|
machine,
|
|
).Scan(&row.project, &row.cwd)
|
|
if err == sql.ErrNoRows {
|
|
return false, nil
|
|
}
|
|
if err != nil {
|
|
return false, fmt.Errorf(
|
|
"reading session %s for worktree mapping apply: %w",
|
|
sessionID,
|
|
err,
|
|
)
|
|
}
|
|
|
|
update, matched, shouldUpdate := applyMappingToSessionRow(mappings, row)
|
|
if !matched || !shouldUpdate {
|
|
return false, nil
|
|
}
|
|
|
|
changed, err := updateSessionProjectTx(
|
|
ctx, tx, update, bumpLocalModifiedAt,
|
|
)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return false, fmt.Errorf(
|
|
"committing worktree mapping session apply: %w", err,
|
|
)
|
|
}
|
|
return changed > 0, nil
|
|
}
|
|
|
|
func (db *DB) ApplyWorktreeProjectMappingsToSessionsByPath(
|
|
ctx context.Context,
|
|
filePath string,
|
|
) (ApplyWorktreeProjectMappingsResult, error) {
|
|
return db.applyWorktreeProjectMappingsToSessionsByPath(
|
|
ctx, filePath, true,
|
|
)
|
|
}
|
|
|
|
func (db *DB) ApplyWorktreeProjectMappingsToSessionsByPathFromSync(
|
|
ctx context.Context,
|
|
filePath string,
|
|
) (ApplyWorktreeProjectMappingsResult, error) {
|
|
return db.applyWorktreeProjectMappingsToSessionsByPath(
|
|
ctx, filePath, false,
|
|
)
|
|
}
|
|
|
|
func (db *DB) applyWorktreeProjectMappingsToSessionsByPath(
|
|
ctx context.Context,
|
|
filePath string,
|
|
bumpLocalModifiedAt bool,
|
|
) (ApplyWorktreeProjectMappingsResult, error) {
|
|
if err := db.requireWritable(); err != nil {
|
|
return ApplyWorktreeProjectMappingsResult{}, err
|
|
}
|
|
if filePath == "" {
|
|
return ApplyWorktreeProjectMappingsResult{}, nil
|
|
}
|
|
|
|
db.mu.Lock()
|
|
defer db.mu.Unlock()
|
|
|
|
tx, err := db.getWriter().BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return ApplyWorktreeProjectMappingsResult{}, fmt.Errorf(
|
|
"beginning worktree mapping path apply: %w", err,
|
|
)
|
|
}
|
|
defer func() { _ = tx.Rollback() }()
|
|
|
|
rows, err := tx.QueryContext(ctx, `
|
|
SELECT id, machine, project, cwd, file_path
|
|
FROM sessions
|
|
WHERE file_path = ? AND deleted_at IS NULL`,
|
|
filePath,
|
|
)
|
|
if err != nil {
|
|
return ApplyWorktreeProjectMappingsResult{}, fmt.Errorf(
|
|
"querying sessions for worktree mapping path apply: %w", err,
|
|
)
|
|
}
|
|
|
|
var sessions []worktreeMappingSessionRow
|
|
machines := map[string]bool{}
|
|
for rows.Next() {
|
|
var row worktreeMappingSessionRow
|
|
var rowFilePath sql.NullString
|
|
if err := rows.Scan(
|
|
&row.id, &row.machine, &row.project, &row.cwd, &rowFilePath,
|
|
); err != nil {
|
|
rows.Close()
|
|
return ApplyWorktreeProjectMappingsResult{}, fmt.Errorf(
|
|
"scanning session for worktree mapping path apply: %w",
|
|
err,
|
|
)
|
|
}
|
|
if rowFilePath.Valid {
|
|
row.filePath = rowFilePath.String
|
|
}
|
|
sessions = append(sessions, row)
|
|
machines[row.machine] = true
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
rows.Close()
|
|
return ApplyWorktreeProjectMappingsResult{}, fmt.Errorf(
|
|
"iterating sessions for worktree mapping path apply: %w",
|
|
err,
|
|
)
|
|
}
|
|
if err := rows.Close(); err != nil {
|
|
return ApplyWorktreeProjectMappingsResult{}, fmt.Errorf(
|
|
"closing worktree mapping path apply rows: %w", err,
|
|
)
|
|
}
|
|
if len(sessions) == 0 {
|
|
return ApplyWorktreeProjectMappingsResult{}, nil
|
|
}
|
|
|
|
mappingsByMachine, err := loadActiveWorktreeMappingsByMachineTx(
|
|
ctx, tx, machines,
|
|
)
|
|
if err != nil {
|
|
return ApplyWorktreeProjectMappingsResult{}, fmt.Errorf(
|
|
"loading active worktree mappings: %w", err,
|
|
)
|
|
}
|
|
|
|
applyWorktreeMappingMatchCwdFromSiblings(sessions, func(row worktreeMappingSessionRow) string {
|
|
return row.machine + "|" + strings.TrimSpace(row.filePath)
|
|
}, func(row worktreeMappingSessionRow, cwd string) (string, bool) {
|
|
return ResolveWorktreeProjectFromSortedMappings(
|
|
mappingsByMachine[row.machine], cwd, row.project,
|
|
)
|
|
})
|
|
|
|
var result ApplyWorktreeProjectMappingsResult
|
|
for _, session := range sessions {
|
|
update, matched, shouldUpdate := applyMappingToSessionRow(
|
|
mappingsByMachine[session.machine],
|
|
session,
|
|
)
|
|
if !matched {
|
|
continue
|
|
}
|
|
result.MatchedSessions++
|
|
if !shouldUpdate {
|
|
continue
|
|
}
|
|
changed, err := updateSessionProjectTx(
|
|
ctx, tx, update, bumpLocalModifiedAt,
|
|
)
|
|
if err != nil {
|
|
return result, err
|
|
}
|
|
result.UpdatedSessions += changed
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return result, fmt.Errorf(
|
|
"committing worktree mapping path apply: %w", err,
|
|
)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func isSQLiteUniqueConstraint(err error) bool {
|
|
var sqliteErr sqlite3.Error
|
|
return errors.As(err, &sqliteErr) &&
|
|
sqliteErr.ExtendedCode == sqlite3.ErrConstraintUnique
|
|
}
|