diff --git a/.gitignore b/.gitignore index eaf97cf..5d6ac44 100644 --- a/.gitignore +++ b/.gitignore @@ -29,5 +29,8 @@ coverage.html # Worktrees .worktrees/ +# eval workspaces +plan-* + # Config (contains user-specific settings) # Note: ~/.arc/cli-config.json is user config diff --git a/cmd/arc/ai.go b/cmd/arc/ai.go index 9ee2ba9..e304551 100644 --- a/cmd/arc/ai.go +++ b/cmd/arc/ai.go @@ -142,7 +142,7 @@ func runSessionStart(cmd *cobra.Command, useStdin bool) error { } resolvedProjectID, err := resolveFromServer(cwd) - if err != nil || resolvedProjectID == "" { + if err != nil { // CWD doesn't map to a registered project — expected skip, not an error. return errSkipSession } diff --git a/cmd/arc/main.go b/cmd/arc/main.go index 58fac14..59a0f2d 100644 --- a/cmd/arc/main.go +++ b/cmd/arc/main.go @@ -10,7 +10,6 @@ import ( "fmt" "io" "os" - "os/exec" "path/filepath" "strings" "text/tabwriter" @@ -195,7 +194,7 @@ func getProjectID() (string, error) { // resolveProject returns the project ID, source, and error. // Resolution priority: // 1. CLI flag (--project) - explicit override always works -// 2. Server path matching (exact, normalized, worktree, then subdirectory) +// 2. Server path matching (delegates all resolution to server) // 3. Legacy config fallback (~/.arc/projects/ configs from before server-side paths) // // If none is available, an error is returned. There is no global fallback @@ -214,7 +213,7 @@ func resolveProject() (wsID string, source ProjectSource, warning string, err er arcHome := project.DefaultArcHome() // Priority 2: Server path matching (checks workspace_paths table, handles symlinks) - if serverWsID, serverErr := resolveFromServer(cwd); serverErr == nil && serverWsID != "" { + if serverWsID, serverErr := resolveFromServer(cwd); serverErr == nil { return serverWsID, ProjectSourceServer, "", nil } @@ -232,93 +231,22 @@ func resolveProject() (wsID string, source ProjectSource, warning string, err er " Run 'arc init' to set up a project, or use '--project ' to specify one") } -// resolveFromServer attempts to resolve the project by querying the server. -// Tries exact path, normalized path, git worktree, then subdirectory matching. +// resolveFromServer asks the server to resolve the given cwd to a project ID. +// The server's resolver handles exact match, longest-prefix match against +// registered workspace paths, and linked-git-worktree detection. func resolveFromServer(cwd string) (string, error) { c, err := getClient() if err != nil { return "", err } - // Try server-side resolution first (checks workspace_paths table) if res, resolveErr := c.ResolveProjectByPath(cwd); resolveErr == nil && res.ProjectID != "" { return res.ProjectID, nil } - // Fall back to normalized path matching - normalizedCwd := project.NormalizePath(cwd) - if normalizedCwd != cwd { - if res, resolveErr := c.ResolveProjectByPath(normalizedCwd); resolveErr == nil && res.ProjectID != "" { - return res.ProjectID, nil - } - } - - // Fall back to git worktree main repo detection - if mainRepo := detectGitMainWorktree(cwd); mainRepo != "" { - if res, resolveErr := c.ResolveProjectByPath(mainRepo); resolveErr == nil && res.ProjectID != "" { - return res.ProjectID, nil - } - normalizedRepo := project.NormalizePath(mainRepo) - if normalizedRepo != mainRepo { - if res, resolveErr := c.ResolveProjectByPath(normalizedRepo); resolveErr == nil && res.ProjectID != "" { - return res.ProjectID, nil - } - } - } - - // Fall back to subdirectory matching - return resolveBySubdirectory(c, cwd, normalizedCwd) -} - -// resolveBySubdirectory checks if cwd is inside any registered workspace path. -func resolveBySubdirectory(c *client.Client, cwd, normalizedCwd string) (string, error) { - projects, listErr := c.ListProjects() - if listErr != nil { - return "", listErr //nolint:wrapcheck // caller wraps - } - for _, proj := range projects { - workspaces, wsErr := c.ListWorkspaces(proj.ID) - if wsErr != nil { - continue - } - for _, ws := range workspaces { - if isSubdirectory(ws.Path, cwd) || isSubdirectory(ws.Path, normalizedCwd) { - return proj.ID, nil - } - } - } - return "", nil -} - -// detectGitMainWorktree returns the main worktree path if cwd is a linked git worktree. -// Returns empty string if cwd is not a worktree or is the main worktree itself. -func detectGitMainWorktree(dir string) string { - cmd := exec.Command("git", "-C", dir, "rev-parse", "--git-common-dir") - out, err := cmd.Output() - if err != nil { - return "" - } - commonDir := strings.TrimSpace(string(out)) - - // If git-common-dir == .git, we're in the main worktree - if commonDir == ".git" { - return "" - } - - // commonDir is an absolute path to the .git dir of the main repo - // The main repo is the parent of the .git dir - if !filepath.IsAbs(commonDir) { - commonDir = filepath.Join(dir, commonDir) - } - commonDir = filepath.Clean(commonDir) - mainRepo := filepath.Dir(commonDir) - - // Sanity check: don't return the same dir - if mainRepo == dir || mainRepo == "." || mainRepo == "" { - return "" - } - - return mainRepo + return "", errors.New( + "no project configured for this directory\n" + + " Run 'arc init' to set up a project, or use '--project ' to specify one") } // resolveFromLegacyConfig attempts to resolve project from ~/.arc/projects/ config. @@ -368,22 +296,6 @@ func outputResult(data any) { } } -// isSubdirectory returns true if child is the same as or a subdirectory of parent. -func isSubdirectory(parent, child string) bool { - // Clean paths for consistent comparison - parent = filepath.Clean(parent) - child = filepath.Clean(child) - - // Exact match - if parent == child { - return true - } - - // Check if child starts with parent + separator - // This prevents /home/foo/project matching /home/foo/project2 - return strings.HasPrefix(child, parent+string(filepath.Separator)) -} - // rootCmd is the top-level Cobra command for the arc CLI. var rootCmd = &cobra.Command{ Use: "arc", diff --git a/go.mod b/go.mod index f1a3baa..e57b5b6 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ tool github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen require ( github.com/blevesearch/bleve/v2 v2.5.7 + github.com/fatih/color v1.18.0 github.com/getkin/kin-openapi v0.133.0 github.com/labstack/echo/v4 v4.15.0 github.com/oapi-codegen/runtime v1.1.2 @@ -40,7 +41,6 @@ require ( github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect github.com/dustin/go-humanize v1.0.1 // indirect - github.com/fatih/color v1.18.0 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/swag v0.23.0 // indirect github.com/golang/snappy v0.0.4 // indirect diff --git a/go.sum b/go.sum index d7ebad0..9e9960a 100644 --- a/go.sum +++ b/go.sum @@ -1,19 +1,8 @@ -filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= -github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= -github.com/ClickHouse/ch-go v0.67.0/go.mod h1:2MSAeyVmgt+9a2k2SQPPG1b4qbTPzdGDpf1+bcHh+18= -github.com/ClickHouse/clickhouse-go/v2 v2.40.1/go.mod h1:GDzSBLVhladVm8V01aEB36IoBOVLLICfyeuiIp/8Ezc= -github.com/CloudyKit/fastprinter v0.0.0-20200109182630-33d98a066a53/go.mod h1:+3IMCy2vIlbG1XG/0ggNQv0SvxCAIpPM5b1nCz56Xno= -github.com/CloudyKit/jet/v6 v6.2.0/go.mod h1:d3ypHeIRNo2+XyqnGA8s+aphtcVpjP5hPwP/Lzo7Ro4= -github.com/Joker/jade v1.1.3/go.mod h1:T+2WLyt7VH6Lp0TRxQrUYEs64nRc83wkMQrfeIQKduM= github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= github.com/RoaringBitmap/roaring/v2 v2.4.5 h1:uGrrMreGjvAtTBobc0g5IrW1D5ldxDQYe2JW2gggRdg= github.com/RoaringBitmap/roaring/v2 v2.4.5/go.mod h1:FiJcsfkGje/nZBZgCu0ZxCPOKD/hVXDS2dXi7/eUFE0= -github.com/Shopify/goreferrer v0.0.0-20220729165902-8cddb4f5de06/go.mod h1:7erjKLwalezA0k99cWs5L11HWOAPNjdUZ6RxH1BXbbM= -github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= -github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g= github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= -github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/bits-and-blooms/bitset v1.12.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= github.com/bits-and-blooms/bitset v1.22.0 h1:Tquv9S8+SGaS3EhyA+up3FXzmkhxPGjQQCkcs2uw7w4= github.com/bits-and-blooms/bitset v1.22.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= @@ -25,10 +14,8 @@ github.com/blevesearch/geo v0.2.4 h1:ECIGQhw+QALCZaDcogRTNSJYQXRtC8/m8IKiA706cqk github.com/blevesearch/geo v0.2.4/go.mod h1:K56Q33AzXt2YExVHGObtmRSFYZKYGv0JEN5mdacJJR8= github.com/blevesearch/go-faiss v1.0.26 h1:4dRLolFgjPyjkaXwff4NfbZFdE/dfywbzDqporeQvXI= github.com/blevesearch/go-faiss v1.0.26/go.mod h1:OMGQwOaRRYxrmeNdMrXJPvVx8gBnvE5RYrr0BahNnkk= -github.com/blevesearch/go-metrics v0.0.0-20201227073835-cf1acfcdf475/go.mod h1:9eJDeqxJ3E7WnLebQUlPD7ZjSce7AnDb9vjGmMCbD0A= github.com/blevesearch/go-porterstemmer v1.0.3 h1:GtmsqID0aZdCSNiY8SkuPJ12pD4jI+DdXTAn4YRcHCo= github.com/blevesearch/go-porterstemmer v1.0.3/go.mod h1:angGc5Ht+k2xhJdZi511LtmxuEf0OVpvUUNrwmM1P7M= -github.com/blevesearch/goleveldb v1.0.1/go.mod h1:WrU8ltZbIp0wAoig/MHbrPCXSOLpe79nz5lv5nqfYrQ= github.com/blevesearch/gtreap v0.1.1 h1:2JWigFrzDMR+42WGIN/V2p0cUvn4UP3C4Q5nmaZGW8Y= github.com/blevesearch/gtreap v0.1.1/go.mod h1:QaQyDRAT51sotthUWAH4Sj08awFSSWzgYICSZ3w0tYk= github.com/blevesearch/mmap-go v1.0.4 h1:OVhDhT5B/M1HNPpYPBKIEJaD0F3Si+CrEKULGCDPWmc= @@ -37,10 +24,8 @@ github.com/blevesearch/scorch_segment_api/v2 v2.3.13 h1:ZPjv/4VwWvHJZKeMSgScCapO github.com/blevesearch/scorch_segment_api/v2 v2.3.13/go.mod h1:ENk2LClTehOuMS8XzN3UxBEErYmtwkE7MAArFTXs9Vc= github.com/blevesearch/segment v0.9.1 h1:+dThDy+Lvgj5JMxhmOVlgFfkUtZV2kw49xax4+jTfSU= github.com/blevesearch/segment v0.9.1/go.mod h1:zN21iLm7+GnBHWTao9I+Au/7MBiL8pPFtJBJTsk6kQw= -github.com/blevesearch/snowball v0.6.1/go.mod h1:ZF0IBg5vgpeoUhnMza2v0A/z8m1cWPlwhke08LpNusg= github.com/blevesearch/snowballstem v0.9.0 h1:lMQ189YspGP6sXvZQ4WZ+MLawfV8wOmPoD/iWeNXm8s= github.com/blevesearch/snowballstem v0.9.0/go.mod h1:PivSj3JMc8WuaFkTSRDW2SlrulNWPl4ABg1tC/hlgLs= -github.com/blevesearch/stempel v0.2.0/go.mod h1:wjeTHqQv+nQdbPuJ/YcvOjTInA2EIc6Ks1FoSUzSLvc= github.com/blevesearch/upsidedown_store_api v1.0.2 h1:U53Q6YoWEARVLd1OYNc9kvhBMGZzVrdmaozG2MfoB+A= github.com/blevesearch/upsidedown_store_api v1.0.2/go.mod h1:M01mh3Gpfy56Ps/UXHjEO/knbqyQ1Oamg8If49gRwrQ= github.com/blevesearch/vellum v1.1.0 h1:CinkGyIsgVlYf8Y2LUQHvdelgXr6PYuvoDIajq6yR9w= @@ -58,15 +43,9 @@ github.com/blevesearch/zapx/v15 v15.4.2/go.mod h1:1pssev/59FsuWcgSnTa0OeEpOzmhtm github.com/blevesearch/zapx/v16 v16.2.8 h1:SlnzF0YGtSlrsOE3oE7EgEX6BIepGpeqxs1IjMbHLQI= github.com/blevesearch/zapx/v16 v16.2.8/go.mod h1:murSoCJPCk25MqURrcJaBQ1RekuqSCSfMjXH4rHyA14= github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= -github.com/bytedance/sonic v1.10.0-rc3/go.mod h1:iZcSUejdk5aukTND/Eu/ivjQuEL0Cu9/rf50Hi0u/g4= -github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d/go.mod h1:8EPpVsBuRksnlj1mLy4AWzRNQYxauNi62uWcE3to6eA= -github.com/chenzhuoyu/iasm v0.9.0/go.mod h1:Xjy2NpN3h7aUqeqM+woSuuvxmIe6+DDsiNLIrkAmYog= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= -github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs= -github.com/couchbase/ghistogram v0.1.0/go.mod h1:s1Jhy76zqfEecpNWJfWUiKZookAFaiGOEoyzgHt9i7k= -github.com/couchbase/moss v0.2.0/go.mod h1:9MaHIaRuy9pvLPUJxB8sh8OrLfyDczECVL37grCIubs= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -77,37 +56,20 @@ github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 h1:PRxIJD8XjimM5aT github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936/go.mod h1:ttYvX5qlB+mlV1okblJqcSMtR4c52UKxDiX9GRBS8+Q= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/elastic/go-sysinfo v1.15.4/go.mod h1:ZBVXmqS368dOn/jvijV/zHLfakWTYHBZPk3G244lHrU= -github.com/elastic/go-windows v1.0.2/go.mod h1:bGcDpBzXgYSqM0Gx3DM4+UxFj300SZLixie9u9ixLM8= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= -github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= -github.com/flosch/pongo2/v4 v4.0.2/go.mod h1:B5ObFANs/36VwxxlgKpdchIJHMvHB562PW+BWPhwZD8= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= -github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA= github.com/getkin/kin-openapi v0.133.0 h1:pJdmNohVIJ97r4AUFtEXRXwESr8b0bD721u/Tz6k8PQ= github.com/getkin/kin-openapi v0.133.0/go.mod h1:boAciF6cXk5FhPqe/NQeBTeenbjqU4LhWBf09ILVvWE= -github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= -github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU= -github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw= -github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo= github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= -github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= -github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= -github.com/go-playground/validator/v10 v10.14.1/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU= -github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= -github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= -github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= -github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= -github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= @@ -117,10 +79,8 @@ github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvq github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= -github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= -github.com/gomarkdown/markdown v0.0.0-20230922112808-5421fefb8386/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= @@ -128,40 +88,22 @@ github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c= -github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/iris-contrib/schema v0.0.6/go.mod h1:iYszG0IOsuIsfzjymw1kMzTL8YQcCWlm65f3wX8J5iA= -github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= -github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= -github.com/jackc/pgx/v5 v5.7.5/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= -github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= -github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= -github.com/kataras/blocks v0.0.7/go.mod h1:UJIU97CluDo0f+zEjbnbkeMRlvYORtmc1304EeyXf4I= -github.com/kataras/golog v0.1.9/go.mod h1:jlpk/bOaYCyqDqH18pgDHdaJab72yBE6i0O3s30hpWY= -github.com/kataras/iris/v12 v12.2.6-0.20230908161203-24ba4e8933b9/go.mod h1:ldkoR3iXABBeqlTibQ3MYaviA1oSlPvim6f55biwBh4= -github.com/kataras/pio v0.0.12/go.mod h1:ODK/8XBhhQ5WqrAhKy+9lTPS7sBf6O3KcLhc9klfRcY= -github.com/kataras/sitemap v0.0.6/go.mod h1:dW4dOCNs896OR1HmG+dMLdT7JjDk7mYBzoIRwuj5jA4= -github.com/kataras/tunnel v0.0.4/go.mod h1:9FkU4LaeifdMWqZu7o20ojmW4B7hdhv2CMLwfnHGpYw= -github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= -github.com/klauspost/cpuid/v2 v2.2.5/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= @@ -173,8 +115,6 @@ github.com/labstack/echo/v4 v4.15.0 h1:hoRTKWcnR5STXZFe9BmYun9AMTNeSbjHi2vtDuADJ github.com/labstack/echo/v4 v4.15.0/go.mod h1:xmw1clThob0BSVRX1CRQkGQ/vjwcpOMjQZSZa9fKA/c= github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= -github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4= -github.com/mailgun/raymond/v2 v2.0.48/go.mod h1:lsgvL50kgt1ylcFJYZiULi5fjPBkkhNfj4KA0W54Z18= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= @@ -183,9 +123,6 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= -github.com/mfridman/xflag v0.1.0/go.mod h1:/483ywM5ZO5SuMVjrIGquYNE5CzLrj5Ux/LxWWnjRaE= -github.com/microcosm-cc/bluemonday v1.0.25/go.mod h1:ZIOjCQp1OrzBBPIJmfX4qDYFuhU02nx4bn030ixfHLE= -github.com/microsoft/go-mssqldb v1.9.2/go.mod h1:GBbW9ASTiDC+mpgWDGKdm3FnFLTUsLYN3iFL90lQ+PA= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -220,30 +157,22 @@ github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1y github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY= github.com/onsi/gomega v1.19.0 h1:4ieX6qQjPP/BfC3mpsAtIGGlxTWPeA3Inl/7DtXw1tw= github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro= -github.com/paulmach/orb v0.11.1/go.mod h1:5mULz1xQfs3bmQm63QEJA6lNGujuRafwA5S/EnuLaLU= -github.com/pelletier/go-toml/v2 v2.0.9/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s= github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw= -github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pressly/goose/v3 v3.26.0 h1:KJakav68jdH0WDvoAcj8+n61WqOIaPGgH0bJWS6jpmM= github.com/pressly/goose/v3 v3.26.0/go.mod h1:4hC1KrritdCxtuFsqgs1R4AU5bWtTAf+cnWvfhf2DNY= -github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/schollz/closestmatch v2.1.0+incompatible/go.mod h1:RtP1ddjLong6gTkbtmuhtR2uUrrJOpYzYRvbcPAid+g= -github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= -github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= -github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/speakeasy-api/jsonpath v0.6.0 h1:IhtFOV9EbXplhyRqsVhHoBmmYjblIRh5D1/g8DHMXJ8= github.com/speakeasy-api/jsonpath v0.6.0/go.mod h1:ymb2iSkyOycmzKwbEAYPJV/yi2rSmvBCLZJcyD+VVWw= github.com/speakeasy-api/openapi-overlay v0.10.2 h1:VOdQ03eGKeiHnpb1boZCGm7x8Haj6gST0P3SGTX95GU= @@ -254,45 +183,28 @@ github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/tdewolff/minify/v2 v2.12.9/go.mod h1:qOqdlDfL+7v0/fyymB+OP497nIxJYSvX4MQWA8OoiXU= -github.com/tdewolff/parse/v2 v2.6.8/go.mod h1:XHDhaU6IBgsryfdnpzUXBlT6leW/l25yrFBTEb4eIyM= -github.com/tursodatabase/libsql-client-go v0.0.0-20240902231107-85af5b9d094d/go.mod h1:l8xTsYB90uaVdMHXMCxKKLSgw5wLYBwBKKefNIUnm9s= -github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= -github.com/vertica/vertica-sql-go v1.3.3/go.mod h1:jnn2GFuv+O2Jcjktb7zyc4Utlbu9YVqpHH/lx63+1M4= -github.com/vmihailenco/msgpack/v5 v5.3.5/go.mod h1:7xyJ9e+0+9SaZT0Wt1RGleJXzli6Q/V5KbhBonMG9jc= -github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= github.com/vmware-labs/yaml-jsonpath v0.3.2 h1:/5QKeCBGdsInyDCyVNLbXyilb61MXGi9NP674f9Hobk= github.com/vmware-labs/yaml-jsonpath v0.3.2/go.mod h1:U6whw1z03QyqgWdgXxvVnQ90zN1BWz5V+51Ewf8k+rQ= github.com/woodsbury/decimal128 v1.3.0 h1:8pffMNWIlC0O5vbyHWFZAt5yWvWcrHA+3ovIIjVWss0= github.com/woodsbury/decimal128 v1.3.0/go.mod h1:C5UTmyTjW3JftjUFzOVhC20BEQa2a4ZKOB5I6Zjb+ds= -github.com/ydb-platform/ydb-go-genproto v0.0.0-20241112172322-ea1f63298f77/go.mod h1:Er+FePu1dNUieD+XTMDduGpQuCPssK5Q4BjF+IIXJ3I= -github.com/ydb-platform/ydb-go-sdk/v3 v3.108.1/go.mod h1:l5sSv153E18VvYcsmr51hok9Sjc16tEC8AXGbwrk+ho= -github.com/yosssi/ace v0.0.5/go.mod h1:ALfIzm2vT7t5ZE7uoIZqF3TQ7SAOyupFZnkrF5id+K0= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -github.com/ziutek/mymysql v1.5.4/go.mod h1:LMSpPZ6DbqWFxNCHW77HeMg9I646SAhApZ/wKdgO/C0= go.etcd.io/bbolt v1.4.0 h1:TU77id3TnN/zKr7CO/uk+fBCwF2jGcMuw2B/FMAzYIk= go.etcd.io/bbolt v1.4.0/go.mod h1:AsD+OCi/qPN1giOX1aiLAha3o1U8rAz65bvN4j0sRuk= -go.etcd.io/gofail v0.2.0/go.mod h1:nL3ILMGfkXTekKI3clMBNazKnjUZjYLKmBHzsVAnC1o= -go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= -go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/arch v0.4.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= @@ -335,10 +247,8 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/telemetry v0.0.0-20260109210033-bd525da824e2/go.mod h1:b7fPSJ0pKZ3ccUh8gnTONJxhn3c/PS6tyzQvyqw4iA8= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= @@ -352,15 +262,10 @@ golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtn golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= -golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= -golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated/go.mod h1:RVAQXBGNv1ib0J382/DPCRS/BPnsGebyM1Gj5VSDpG8= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20220411194840-2f41105eb62f/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/genproto/googleapis/rpc v0.0.0-20240123012728-ef4313101c80/go.mod h1:PAREbraiVEVGVdTZsVWjSbbTtSyGbAgIIvni8a8CD5s= -google.golang.org/grpc v1.62.1/go.mod h1:IWTG0VlJLCh1SkC58F7np9ka9mx/WNkjl4PGJaiq+QE= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= @@ -376,7 +281,6 @@ gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= -gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= @@ -390,7 +294,6 @@ gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc= diff --git a/internal/api/ai_sessions.go b/internal/api/ai_sessions.go index e1d2346..d9ecda2 100644 --- a/internal/api/ai_sessions.go +++ b/internal/api/ai_sessions.go @@ -73,7 +73,7 @@ func (s *Server) createAISession(c echo.Context) error { // Validate CWD maps to the given project if req.CWD != "" { - ws, err := s.store.ResolveProjectByPath(ctx, req.CWD) + ws, err := s.resolveProjectForPath(ctx, req.CWD) if err != nil { log.Printf("WARN: CWD %q does not resolve to any project: %v", req.CWD, err) return errorJSON(c, http.StatusUnprocessableEntity, diff --git a/internal/api/ai_sessions_test.go b/internal/api/ai_sessions_test.go index 1bcced1..da5a106 100644 --- a/internal/api/ai_sessions_test.go +++ b/internal/api/ai_sessions_test.go @@ -8,9 +8,11 @@ import ( "net/http/httptest" "os" "path/filepath" + "strings" "testing" "github.com/labstack/echo/v4" + "github.com/sentiolabs/arc/internal/testutil/gittest" "github.com/sentiolabs/arc/internal/types" ) @@ -1057,3 +1059,92 @@ func TestListAISessionsWithSummary(t *testing.T) { s2.AgentSummary) } } + +// TestCreateAISession_WorktreePathUnderRegistered verifies that a CWD that is a +// subdirectory of a registered workspace path resolves correctly (prefix match). +func TestCreateAISession_WorktreePathUnderRegistered(t *testing.T) { + server, cleanup := testServer(t) + defer cleanup() + e := server.echo + + projID := createNamedProject(t, e, "worktree-prefix-proj", "wtp") + addWorkspaceToProject(t, e, projID, "/repos/main") + + body := strings.NewReader(`{"id":"sess-wt-1","cwd":"/repos/main/.worktrees/feature-x"}`) + req := httptest.NewRequest( + http.MethodPost, + "/api/v1/projects/"+projID+"/ai/sessions", + body, + ) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + + if rec.Code != http.StatusCreated { + t.Fatalf("status = %d, want %d. body=%s", + rec.Code, http.StatusCreated, rec.Body.String()) + } +} + +// TestCreateAISession_LinkedWorktreeOutsideRegistered verifies that a CWD inside +// a linked git worktree (outside the registered prefix) resolves via DetectMainRepo. +func TestCreateAISession_LinkedWorktreeOutsideRegistered(t *testing.T) { + server, cleanup := testServer(t) + defer cleanup() + e := server.echo + + tmp := t.TempDir() + mainDir := filepath.Join(tmp, "main") + wtDir := filepath.Join(tmp, "feature-x") + + // Real git init + worktree add so DetectMainRepo can follow the .git pointer. + gittest.InitRepo(t, mainDir) + gittest.AddWorktree(t, mainDir, wtDir, "feature-x") + + projID := createNamedProject(t, e, "linked-worktree-proj", "lwp") + addWorkspaceToProject(t, e, projID, mainDir) + + body := strings.NewReader(fmt.Sprintf(`{"id":"sess-wt-2","cwd":%q}`, wtDir)) + req := httptest.NewRequest( + http.MethodPost, + "/api/v1/projects/"+projID+"/ai/sessions", + body, + ) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + + if rec.Code != http.StatusCreated { + t.Fatalf("status = %d, want %d. body=%s", + rec.Code, http.StatusCreated, rec.Body.String()) + } +} + +// TestCreateAISession_RejectCrossProject verifies that posting to project A with +// a CWD registered under project B is still rejected with 422. +func TestCreateAISession_RejectCrossProject(t *testing.T) { + server, cleanup := testServer(t) + defer cleanup() + e := server.echo + + projAID := createNamedProject(t, e, "cross-proj-a", "cpa") + projBID := createNamedProject(t, e, "cross-proj-b", "cpb") + addWorkspaceToProject(t, e, projAID, "/repos/a") + addWorkspaceToProject(t, e, projBID, "/repos/b") + + // Posting to project A with a CWD under project B's prefix should fail. + body := strings.NewReader(`{"id":"sess-wt-3","cwd":"/repos/b/sub/dir"}`) + req := httptest.NewRequest( + http.MethodPost, + "/api/v1/projects/"+projAID+"/ai/sessions", + body, + ) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + + if rec.Code != http.StatusUnprocessableEntity { + t.Fatalf("status = %d, want %d. body=%s", + rec.Code, http.StatusUnprocessableEntity, rec.Body.String()) + } +} diff --git a/internal/api/resolver.go b/internal/api/resolver.go new file mode 100644 index 0000000..f9bf349 --- /dev/null +++ b/internal/api/resolver.go @@ -0,0 +1,35 @@ +package api + +import ( + "context" + + "github.com/sentiolabs/arc/internal/gitfs" + "github.com/sentiolabs/arc/internal/types" +) + +// resolveProjectForPath is the canonical server-side path-to-project resolver. +// +// Stages: +// 1. Match `path` exactly or against the longest registered ancestor via +// store.ResolveProjectByPath (prefix-aware). +// 2. If (1) fails and `path` is inside a linked git worktree, retry (1) +// against the main repository's working directory. +// +// Returns the matched workspace, or the underlying not-found error from +// the storage layer if no stage succeeds. +func (s *Server) resolveProjectForPath(ctx context.Context, path string) (*types.Workspace, error) { + ws, err := s.store.ResolveProjectByPath(ctx, path) + if err == nil { + return ws, nil + } + + mainRepo := gitfs.DetectMainRepo(path) + if mainRepo == "" { + return nil, err + } + + if mainWs, retryErr := s.store.ResolveProjectByPath(ctx, mainRepo); retryErr == nil { + return mainWs, nil + } + return nil, err +} diff --git a/internal/api/workspace_paths.go b/internal/api/workspace_paths.go index bd7bc07..246ba07 100644 --- a/internal/api/workspace_paths.go +++ b/internal/api/workspace_paths.go @@ -134,7 +134,7 @@ func (s *Server) resolveProject(c echo.Context) error { return errorJSON(c, http.StatusBadRequest, "path query parameter is required") } - ws, err := s.store.ResolveProjectByPath(ctx, path) + ws, err := s.resolveProjectForPath(ctx, path) if err != nil { return errorJSON(c, http.StatusNotFound, err.Error()) } diff --git a/internal/api/workspace_paths_test.go b/internal/api/workspace_paths_test.go index 664d332..83f220e 100644 --- a/internal/api/workspace_paths_test.go +++ b/internal/api/workspace_paths_test.go @@ -6,11 +6,14 @@ import ( "fmt" "net/http" "net/http/httptest" + "net/url" + "path/filepath" "strings" "testing" "time" "github.com/labstack/echo/v4" + "github.com/sentiolabs/arc/internal/testutil/gittest" "github.com/sentiolabs/arc/internal/types" ) @@ -88,10 +91,22 @@ func (m *mockWPStore) DeleteWorkspace(_ context.Context, id string) error { } func (m *mockWPStore) ResolveProjectByPath(_ context.Context, path string) (*types.Workspace, error) { + var best *types.Workspace + bestLen := -1 for _, ws := range m.workspaces { if ws.Path == path { return ws, nil } + // Component-wise prefix: ws.Path must be followed by "/" in path. + if strings.HasPrefix(path, ws.Path+"/") { + if len(ws.Path) > bestLen { + best = ws + bestLen = len(ws.Path) + } + } + } + if best != nil { + return best, nil } return nil, fmt.Errorf("no project found for path: %s", path) } @@ -499,3 +514,33 @@ func TestResolveProject(t *testing.T) { t.Errorf("expected UpdateWorkspaceLastAccessed called with p-1, got %s", store.touched) } } + +func TestResolveProject_GitWorktree(t *testing.T) { + tmp := t.TempDir() + mainDir := filepath.Join(tmp, "main") + wtDir := filepath.Join(tmp, "feature-x") + + gittest.InitRepo(t, mainDir) + gittest.AddWorktree(t, mainDir, wtDir, "feature-x") + + srv, cleanup := testServer(t) + defer cleanup() + + projID := createNamedProject(t, srv.echo, "worktree-proj", "wtp") + addWorkspaceToProject(t, srv.echo, projID, mainDir) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/projects/resolve?path="+url.QueryEscape(wtDir), nil) + rec := httptest.NewRecorder() + srv.echo.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d. body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + var got types.ProjectResolution + if err := json.NewDecoder(rec.Body).Decode(&got); err != nil { + t.Fatalf("decode: %v", err) + } + if got.ProjectID != projID { + t.Errorf("ProjectID = %q, want %q", got.ProjectID, projID) + } +} diff --git a/internal/gitfs/worktree.go b/internal/gitfs/worktree.go new file mode 100644 index 0000000..5264622 --- /dev/null +++ b/internal/gitfs/worktree.go @@ -0,0 +1,114 @@ +// Package gitfs provides pure-Go helpers for inspecting on-disk git +// repositories. It does NOT shell out to the git binary and does NOT +// depend on any third-party git library. It only handles the narrow +// problems arc needs: locating .git entries and following linked +// worktree pointers. +package gitfs + +import ( + "os" + "path/filepath" + "strings" +) + +// FindGitEntry walks up from dir to locate a .git entry (file or +// directory). Returns the absolute path to the .git entry, or "" if +// none is found before reaching the filesystem root. +func FindGitEntry(dir string) string { + dir = filepath.Clean(dir) + for { + candidate := filepath.Join(dir, ".git") + if _, err := os.Lstat(candidate); err == nil { + return candidate + } + parent := filepath.Dir(dir) + if parent == dir { + return "" + } + dir = parent + } +} + +// DetectMainRepo returns the canonical path of the main repository if dir +// is inside a linked git worktree. Returns "" if dir is in the main +// worktree, has no reachable .git entry, or the .git pointer is malformed. +// +// Two layouts are supported: +// +// 1. Worktree of a normal repo: .git is a file like +// "gitdir: /abs/path/main/.git/worktrees/". This function returns +// the main repo's working directory (parent of the .git directory). +// +// 2. Worktree of a bare repo: .git is a file like +// "gitdir: /abs/path/repo.git/worktrees/". A bare repo has no +// working directory; this function returns the bare repo path itself +// (validated by checking for HEAD and objects/). +func DetectMainRepo(dir string) string { + gitPath := FindGitEntry(dir) + if gitPath == "" { + return "" + } + + info, err := os.Lstat(gitPath) + if err != nil { + return "" + } + if info.IsDir() { + return "" + } + + data, err := os.ReadFile(gitPath) + if err != nil { + return "" + } + firstLine, _, _ := strings.Cut(string(data), "\n") + line := strings.TrimSpace(firstLine) + const prefix = "gitdir:" + if !strings.HasPrefix(line, prefix) { + return "" + } + gitdir := strings.TrimSpace(strings.TrimPrefix(line, prefix)) + if !filepath.IsAbs(gitdir) { + return "" + } + + worktreesDir := filepath.Dir(gitdir) + if filepath.Base(worktreesDir) != "worktrees" { + return "" + } + + mainGitDir := filepath.Dir(worktreesDir) + + // Normal repo case: gitdir is ...//.git/worktrees/. + // The parent of mainGitDir is the main worktree's working directory. + if filepath.Base(mainGitDir) == ".git" { + return filepath.Dir(mainGitDir) + } + + // Bare repo case: gitdir is .../repo.git/worktrees/. There is no + // working directory; the bare repo itself (mainGitDir) is the canonical + // path users register. Validate it looks like a real bare repo by + // checking for the standard bare-repo files HEAD and objects. + if isBareRepo(mainGitDir) { + return mainGitDir + } + + return "" +} + +// isBareRepo returns true if dir contains the typical structural files +// of a bare git repository (HEAD file and objects directory). +func isBareRepo(dir string) bool { + // dir originates from a parsed .git pointer (validated absolute) and is + // joined with hardcoded leaf names; gosec's taint analysis can't follow + // that flow. + //nolint:gosec // G304/G703: dir is validated; leaf names are constants + if info, err := os.Stat(filepath.Join(dir, "HEAD")); err != nil || info.IsDir() { + return false + } + //nolint:gosec // G304/G703: dir is validated; leaf names are constants + if info, err := os.Stat(filepath.Join(dir, "objects")); err != nil || !info.IsDir() { + return false + } + return true +} diff --git a/internal/gitfs/worktree_test.go b/internal/gitfs/worktree_test.go new file mode 100644 index 0000000..63a61db --- /dev/null +++ b/internal/gitfs/worktree_test.go @@ -0,0 +1,167 @@ +package gitfs_test + +import ( + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/sentiolabs/arc/internal/gitfs" + "github.com/sentiolabs/arc/internal/testutil/gittest" +) + +// --- Contract assertions --- + +var ( + _ func(string) string = gitfs.FindGitEntry + _ func(string) string = gitfs.DetectMainRepo +) + +// --- Behavior tests --- + +func TestFindGitEntry_MainWorktree(t *testing.T) { + root := t.TempDir() + main := filepath.Join(root, "main") + gittest.InitRepo(t, main) + + got := gitfs.FindGitEntry(main) + want := filepath.Join(main, ".git") + if got != want { + t.Fatalf("FindGitEntry(%q) = %q, want %q", main, got, want) + } +} + +func TestFindGitEntry_FromSubdirectory(t *testing.T) { + root := t.TempDir() + main := filepath.Join(root, "main") + sub := filepath.Join(main, "deep", "nested", "dir") + gittest.InitRepo(t, main) + if err := os.MkdirAll(sub, 0o755); err != nil { + t.Fatalf("mkdir sub: %v", err) + } + + got := gitfs.FindGitEntry(sub) + want := filepath.Join(main, ".git") + if got != want { + t.Fatalf("FindGitEntry(%q) = %q, want %q", sub, got, want) + } +} + +func TestFindGitEntry_NoRepo(t *testing.T) { + root := t.TempDir() + got := gitfs.FindGitEntry(root) + if got != "" { + t.Fatalf("FindGitEntry(%q) = %q, want empty", root, got) + } +} + +func TestDetectMainRepo_MainWorktreeReturnsEmpty(t *testing.T) { + root := t.TempDir() + main := filepath.Join(root, "main") + gittest.InitRepo(t, main) + + if got := gitfs.DetectMainRepo(main); got != "" { + t.Fatalf("DetectMainRepo(main) = %q, want empty", got) + } +} + +func TestDetectMainRepo_LinkedWorktreeRoot(t *testing.T) { + root := t.TempDir() + main := filepath.Join(root, "main") + wt := filepath.Join(root, "feature-x") + gittest.InitRepo(t, main) + gittest.AddWorktree(t, main, wt, "feature-x") + + got := gitfs.DetectMainRepo(wt) + if got != main { + t.Fatalf("DetectMainRepo(%q) = %q, want %q", wt, got, main) + } +} + +func TestDetectMainRepo_LinkedWorktreeSubdir(t *testing.T) { + root := t.TempDir() + main := filepath.Join(root, "main") + wt := filepath.Join(root, "feature-x") + gittest.InitRepo(t, main) + gittest.AddWorktree(t, main, wt, "feature-x") + + sub := filepath.Join(wt, "internal", "deep") + if err := os.MkdirAll(sub, 0o755); err != nil { + t.Fatalf("mkdir sub: %v", err) + } + + got := gitfs.DetectMainRepo(sub) + if got != main { + t.Fatalf("DetectMainRepo(%q) = %q, want %q", sub, got, main) + } +} + +func TestDetectMainRepo_NoRepo(t *testing.T) { + root := t.TempDir() + if got := gitfs.DetectMainRepo(root); got != "" { + t.Fatalf("DetectMainRepo(no-repo) = %q, want empty", got) + } +} + +func TestDetectMainRepo_MalformedGitFile(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, ".git"), []byte("not a gitdir pointer\n"), 0o600); err != nil { + t.Fatalf("write .git: %v", err) + } + if got := gitfs.DetectMainRepo(root); got != "" { + t.Fatalf("DetectMainRepo(malformed) = %q, want empty", got) + } +} + +func TestDetectMainRepo_RelativeGitdir(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, ".git"), []byte("gitdir: ../relative/path\n"), 0o600); err != nil { + t.Fatalf("write .git: %v", err) + } + if got := gitfs.DetectMainRepo(root); got != "" { + t.Fatalf("DetectMainRepo(relative) = %q, want empty", got) + } +} + +func TestDetectMainRepo_BareRepoWorktree(t *testing.T) { + root := t.TempDir() + bare := filepath.Join(root, "repo.git") + wt := filepath.Join(root, "feature-x") + + // Create a bare repo with one commit so worktree add has a ref to branch from. + gittest.Run(t, "", "init", "--bare", "-q", bare) + + // `git worktree add` on a bare repo needs an existing branch. Easiest path: + // init a temporary normal repo, push to bare, then bare repo can serve worktrees. + src := filepath.Join(root, "src") + gittest.InitRepo(t, src) + gittest.Run(t, src, "remote", "add", "origin", bare) + gittest.Run(t, src, "push", "-q", "origin", "HEAD:refs/heads/main") + + // Now add a worktree from the bare repo. + gittest.Run(t, bare, "worktree", "add", "-q", "-b", "feature-x", wt, "main") + t.Cleanup(func() { + _ = exec.Command("git", "worktree", "remove", "--force", wt).Run() + }) + + got := gitfs.DetectMainRepo(wt) + if got != bare { + t.Fatalf("DetectMainRepo(bare-repo-worktree) = %q, want %q", got, bare) + } +} + +func TestDetectMainRepo_PointerToNonRepo(t *testing.T) { + // .git file points at a directory that isn't a git repo (no HEAD, no objects). + root := t.TempDir() + fakeGitdir := filepath.Join(root, "not-a-repo", "worktrees", "x") + if err := os.MkdirAll(fakeGitdir, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(filepath.Join(root, ".git"), []byte("gitdir: "+fakeGitdir+"\n"), 0o600); err != nil { + t.Fatalf("write .git: %v", err) + } + + if got := gitfs.DetectMainRepo(root); got != "" { + t.Fatalf("DetectMainRepo(pointer-to-non-repo) = %q, want empty", got) + } +} diff --git a/internal/storage/sqlite/db/queries/workspaces.sql b/internal/storage/sqlite/db/queries/workspaces.sql index 9b1c49b..c250a97 100644 --- a/internal/storage/sqlite/db/queries/workspaces.sql +++ b/internal/storage/sqlite/db/queries/workspaces.sql @@ -14,11 +14,6 @@ UPDATE workspaces SET label = ?, hostname = ?, git_remote = ?, path_type = ?, up -- name: DeleteWorkspace :exec DELETE FROM workspaces WHERE id = ?; --- name: ResolveProjectByPath :one -SELECT w.* FROM workspaces w -JOIN projects p ON w.project_id = p.id -WHERE w.path = ?; - -- name: UpdateWorkspaceLastAccessed :exec UPDATE workspaces SET last_accessed_at = ?, updated_at = ? WHERE id = ?; diff --git a/internal/storage/sqlite/db/workspaces.sql.go b/internal/storage/sqlite/db/workspaces.sql.go index 5834011..8b3f1fb 100644 --- a/internal/storage/sqlite/db/workspaces.sql.go +++ b/internal/storage/sqlite/db/workspaces.sql.go @@ -161,30 +161,6 @@ func (q *Queries) ListWorkspaces(ctx context.Context, projectID string) ([]*Work return items, nil } -const resolveProjectByPath = `-- name: ResolveProjectByPath :one -SELECT w.id, w.project_id, w.path, w.label, w.hostname, w.git_remote, w.path_type, w.last_accessed_at, w.created_at, w.updated_at FROM workspaces w -JOIN projects p ON w.project_id = p.id -WHERE w.path = ? -` - -func (q *Queries) ResolveProjectByPath(ctx context.Context, path string) (*Workspace, error) { - row := q.db.QueryRowContext(ctx, resolveProjectByPath, path) - var i Workspace - err := row.Scan( - &i.ID, - &i.ProjectID, - &i.Path, - &i.Label, - &i.Hostname, - &i.GitRemote, - &i.PathType, - &i.LastAccessedAt, - &i.CreatedAt, - &i.UpdatedAt, - ) - return &i, err -} - const updateWorkspace = `-- name: UpdateWorkspace :exec UPDATE workspaces SET label = ?, hostname = ?, git_remote = ?, path_type = ?, updated_at = ? WHERE id = ? ` diff --git a/internal/storage/sqlite/workspaces.go b/internal/storage/sqlite/workspaces.go index 102c047..87c98d9 100644 --- a/internal/storage/sqlite/workspaces.go +++ b/internal/storage/sqlite/workspaces.go @@ -113,9 +113,38 @@ func (s *Store) DeleteWorkspace(ctx context.Context, id string) error { return nil } -// ResolveProjectByPath finds a workspace entry by filesystem path. +const resolveProjectByPathSQL = ` + SELECT w.id, w.project_id, w.path, w.label, w.hostname, w.git_remote, + w.path_type, w.last_accessed_at, w.created_at, w.updated_at + FROM workspaces w + JOIN projects p ON w.project_id = p.id + WHERE w.path = ? + OR ? LIKE REPLACE(REPLACE(REPLACE(w.path, '\', '\\'), '%', '\%'), '_', '\_') || '/%' ESCAPE '\' + ORDER BY length(w.path) DESC + LIMIT 1 +` + +// ResolveProjectByPath finds the workspace whose path is `path` exactly or +// is the longest registered ancestor of `path` (component-wise). +// +// The LIKE clause pattern-matches against w.path's value with REPLACE-based +// escaping of \, %, and _ so that workspace paths containing those literal +// characters do not act as SQL LIKE wildcards. ESCAPE '\' marks the +// escape sequences emitted by REPLACE. func (s *Store) ResolveProjectByPath(ctx context.Context, path string) (*types.Workspace, error) { - row, err := s.queries.ResolveProjectByPath(ctx, path) + row := db.Workspace{} + err := s.db.QueryRowContext(ctx, resolveProjectByPathSQL, path, path).Scan( + &row.ID, + &row.ProjectID, + &row.Path, + &row.Label, + &row.Hostname, + &row.GitRemote, + &row.PathType, + &row.LastAccessedAt, + &row.CreatedAt, + &row.UpdatedAt, + ) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, fmt.Errorf("workspace not found for path: %s", path) @@ -123,7 +152,7 @@ func (s *Store) ResolveProjectByPath(ctx context.Context, path string) (*types.W return nil, fmt.Errorf("resolve project by path: %w", err) } - return dbWorkspaceToType(row), nil + return dbWorkspaceToType(&row), nil } // UpdateWorkspaceLastAccessed updates the last_accessed_at timestamp for a workspace. diff --git a/internal/storage/sqlite/workspaces_test.go b/internal/storage/sqlite/workspaces_test.go index 8ba1a26..51fbd5c 100644 --- a/internal/storage/sqlite/workspaces_test.go +++ b/internal/storage/sqlite/workspaces_test.go @@ -6,6 +6,7 @@ import ( "testing" "time" + "github.com/sentiolabs/arc/internal/storage/sqlite" "github.com/sentiolabs/arc/internal/types" ) @@ -448,3 +449,187 @@ func TestUpdateWorkspaceLastAccessed(t *testing.T) { t.Errorf("LastAccessedAt = %v, expected between %v and %v", got.LastAccessedAt, before, after) } } + +func TestResolveProjectByPath_PrefixMatch(t *testing.T) { + store, cleanup := setupTestStore(t) + defer cleanup() + + ctx := context.Background() + proj := setupTestProject(t, store) + + ws := &types.Workspace{ + ProjectID: proj.ID, + Path: "/home/user/projects/myapp", + Label: "main", + } + if err := store.CreateWorkspace(ctx, ws); err != nil { + t.Fatalf("CreateWorkspace failed: %v", err) + } + + got, err := store.ResolveProjectByPath(ctx, "/home/user/projects/myapp/.worktrees/feature-x/internal/api") + if err != nil { + t.Fatalf("ResolveProjectByPath(subdir) failed: %v", err) + } + if got.ID != ws.ID { + t.Errorf("ID = %q, want %q (longest-prefix match)", got.ID, ws.ID) + } +} + +func TestResolveProjectByPath_LongestPrefixWins(t *testing.T) { + store, cleanup := setupTestStore(t) + defer cleanup() + + ctx := context.Background() + projOuter := setupTestProject(t, store) + projInner := setupTestProjectNamed(t, store, "inner-project", "inner") + + wsOuter := &types.Workspace{ProjectID: projOuter.ID, Path: "/repos/outer", Label: "outer"} + wsInner := &types.Workspace{ProjectID: projInner.ID, Path: "/repos/outer/sub/inner", Label: "inner"} + if err := store.CreateWorkspace(ctx, wsOuter); err != nil { + t.Fatalf("CreateWorkspace(outer) failed: %v", err) + } + if err := store.CreateWorkspace(ctx, wsInner); err != nil { + t.Fatalf("CreateWorkspace(inner) failed: %v", err) + } + + got, err := store.ResolveProjectByPath(ctx, "/repos/outer/sub/inner/deep/file") + if err != nil { + t.Fatalf("ResolveProjectByPath(nested) failed: %v", err) + } + if got.ID != wsInner.ID { + t.Errorf("ID = %q, want %q (longest prefix should win)", got.ID, wsInner.ID) + } +} + +func TestResolveProjectByPath_ComponentBoundary(t *testing.T) { + store, cleanup := setupTestStore(t) + defer cleanup() + + ctx := context.Background() + proj := setupTestProject(t, store) + + ws := &types.Workspace{ProjectID: proj.ID, Path: "/repos/proj", Label: "proj"} + if err := store.CreateWorkspace(ctx, ws); err != nil { + t.Fatalf("CreateWorkspace failed: %v", err) + } + + // "/repos/proj-foo" must NOT match "/repos/proj" — different directories. + _, err := store.ResolveProjectByPath(ctx, "/repos/proj-foo/file") + if err == nil { + t.Fatal("ResolveProjectByPath(/repos/proj-foo/file) should not match /repos/proj") + } + if !strings.Contains(err.Error(), "not found") { + t.Errorf("expected 'not found' error, got: %v", err) + } +} + +func TestResolveProjectByPath_LiteralUnderscoreInRegisteredPath(t *testing.T) { + store, cleanup := setupTestStore(t) + defer cleanup() + + ctx := context.Background() + proj := setupTestProject(t, store) + + ws := &types.Workspace{ + ProjectID: proj.ID, + Path: "/home/john_doe/proj", + Label: "with-underscore", + } + if err := store.CreateWorkspace(ctx, ws); err != nil { + t.Fatalf("CreateWorkspace failed: %v", err) + } + + // Exact match must work. + got, err := store.ResolveProjectByPath(ctx, "/home/john_doe/proj") + if err != nil { + t.Fatalf("exact match failed: %v", err) + } + if got.ID != ws.ID { + t.Errorf("exact match resolved to %q, want %q", got.ID, ws.ID) + } + + // Subdirectory of the real path must work. + got, err = store.ResolveProjectByPath(ctx, "/home/john_doe/proj/sub/file") + if err != nil { + t.Fatalf("subdir of real path failed: %v", err) + } + if got.ID != ws.ID { + t.Errorf("subdir of real path resolved to %q, want %q", got.ID, ws.ID) + } + + // A path where the underscore position has a different character must NOT match. + // Without REPLACE-escaping, '_' acts as a single-char wildcard and "/home/johnXdoe/projXfile" + // would falsely match "/home/john_doe/proj". + if _, err := store.ResolveProjectByPath(ctx, "/home/johnXdoe/proj/file"); err == nil { + t.Fatal("path with X where _ was registered must NOT match (LIKE wildcard leak)") + } +} + +func TestResolveProjectByPath_LiteralPercentInRegisteredPath(t *testing.T) { + store, cleanup := setupTestStore(t) + defer cleanup() + + ctx := context.Background() + proj := setupTestProject(t, store) + + ws := &types.Workspace{ + ProjectID: proj.ID, + Path: "/repos/100%coverage", + Label: "with-percent", + } + if err := store.CreateWorkspace(ctx, ws); err != nil { + t.Fatalf("CreateWorkspace failed: %v", err) + } + + // Subdirectory of the real path must work. + got, err := store.ResolveProjectByPath(ctx, "/repos/100%coverage/sub") + if err != nil { + t.Fatalf("subdir of percent path failed: %v", err) + } + if got.ID != ws.ID { + t.Errorf("subdir resolved to %q, want %q", got.ID, ws.ID) + } + + // Without escaping, % would match anything: "/repos/100ANYcoverage/file" should NOT match. + if _, err := store.ResolveProjectByPath(ctx, "/repos/100ANYTHINGcoverage/file"); err == nil { + t.Fatal("path that differs only by what % covers must NOT match") + } +} + +func TestResolveProjectByPath_LiteralBackslashInRegisteredPath(t *testing.T) { + store, cleanup := setupTestStore(t) + defer cleanup() + + ctx := context.Background() + proj := setupTestProject(t, store) + + // Edge case: path containing a literal backslash. Rare on Linux/Mac but possible. + ws := &types.Workspace{ + ProjectID: proj.ID, + Path: `/repos/weird\name/proj`, + Label: "with-backslash", + } + if err := store.CreateWorkspace(ctx, ws); err != nil { + t.Fatalf("CreateWorkspace failed: %v", err) + } + + got, err := store.ResolveProjectByPath(ctx, `/repos/weird\name/proj/sub`) + if err != nil { + t.Fatalf("subdir of backslash path failed: %v", err) + } + if got.ID != ws.ID { + t.Errorf("subdir resolved to %q, want %q", got.ID, ws.ID) + } +} + +func setupTestProjectNamed(t *testing.T, store *sqlite.Store, name, prefix string) *types.Project { + t.Helper() + proj := &types.Project{ + Name: name, + Prefix: prefix, + } + if err := store.CreateProject(context.Background(), proj); err != nil { + t.Fatalf("CreateProject(%s) failed: %v", name, err) + } + return proj +} diff --git a/internal/testutil/gittest/gittest.go b/internal/testutil/gittest/gittest.go new file mode 100644 index 0000000..558528c --- /dev/null +++ b/internal/testutil/gittest/gittest.go @@ -0,0 +1,67 @@ +// Package gittest provides hardened git-command helpers for tests that +// need to set up real git repositories or worktrees in t.TempDir(). +// +// All commands run with system and global git config disabled +// (GIT_CONFIG_NOSYSTEM=1, GIT_CONFIG_GLOBAL=/dev/null) and with deterministic +// author/committer identity, so tests behave the same way on every host. +package gittest + +import ( + "os" + "os/exec" + "testing" +) + +// dirPerm is the permission bits used when creating test repository +// directories. 0o755 matches the default umask-aware mode for `git init`. +const dirPerm = 0o755 + +// Run executes `git ` with cmd.Dir set to workdir and the standard +// hardened test environment. workdir must already exist; if it doesn't, +// Run fails the test. Pass "" for workdir to use the current process's CWD. +func Run(t *testing.T, workdir string, args ...string) { + t.Helper() + cmd := exec.Command("git", args...) + if workdir != "" { + cmd.Dir = workdir + } + cmd.Env = hardenedEnv() + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v: %v\n%s", args, err, out) + } +} + +// InitRepo initializes a normal (non-bare) git repository at dir, creating +// the directory if needed, and makes one empty commit so subsequent +// `git worktree add` calls have a ref to branch from. +func InitRepo(t *testing.T, dir string) { + t.Helper() + if err := os.MkdirAll(dir, dirPerm); err != nil { + t.Fatalf("mkdir %q: %v", dir, err) + } + Run(t, dir, "init", "-q") + Run(t, dir, "commit", "--allow-empty", "-m", "init", "-q") +} + +// AddWorktree creates a linked git worktree at worktreeDir, branching from +// HEAD of mainDir to a new branch named branch. Registers a t.Cleanup that +// runs `git worktree remove --force` so t.TempDir() teardown can succeed +// without lock-file flakes. +func AddWorktree(t *testing.T, mainDir, worktreeDir, branch string) { + t.Helper() + Run(t, mainDir, "worktree", "add", "-q", "-b", branch, worktreeDir) + t.Cleanup(func() { + _ = exec.Command("git", "worktree", "remove", "--force", worktreeDir).Run() + }) +} + +func hardenedEnv() []string { + return append(os.Environ(), + "GIT_AUTHOR_NAME=test", + "GIT_AUTHOR_EMAIL=test@example.com", + "GIT_COMMITTER_NAME=test", + "GIT_COMMITTER_EMAIL=test@example.com", + "GIT_CONFIG_NOSYSTEM=1", + "GIT_CONFIG_GLOBAL=/dev/null", + ) +} diff --git a/internal/testutil/gittest/gittest_test.go b/internal/testutil/gittest/gittest_test.go new file mode 100644 index 0000000..cf04ee3 --- /dev/null +++ b/internal/testutil/gittest/gittest_test.go @@ -0,0 +1,59 @@ +package gittest_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/sentiolabs/arc/internal/testutil/gittest" +) + +// TestRun verifies that Run executes git commands successfully in the given dir. +func TestRun(t *testing.T) { + dir := t.TempDir() + // git init should succeed + gittest.Run(t, dir, "init", "-q") + if _, err := os.Stat(filepath.Join(dir, ".git")); err != nil { + t.Fatalf("expected .git directory after init: %v", err) + } +} + +// TestRun_EmptyWorkdir verifies that Run with "" uses the current process CWD. +func TestRun_EmptyWorkdir(t *testing.T) { + // Just ensure it doesn't panic or fail on a simple no-op command. + // git --version works from any directory. + gittest.Run(t, "", "version") +} + +// TestInitRepo verifies that InitRepo creates a git repository with one commit. +func TestInitRepo(t *testing.T) { + dir := filepath.Join(t.TempDir(), "repo") + gittest.InitRepo(t, dir) + + // The dir must exist and have a .git directory. + if _, err := os.Stat(filepath.Join(dir, ".git")); err != nil { + t.Fatalf("expected .git directory after InitRepo: %v", err) + } + + // There must be at least one commit (so worktree add works). + gittest.Run(t, dir, "log", "--oneline") +} + +// TestAddWorktree verifies that AddWorktree creates a linked worktree. +func TestAddWorktree(t *testing.T) { + root := t.TempDir() + mainDir := filepath.Join(root, "main") + wtDir := filepath.Join(root, "wt") + + gittest.InitRepo(t, mainDir) + gittest.AddWorktree(t, mainDir, wtDir, "feature-test") + + // The worktree dir must exist and contain a .git file (not directory). + info, err := os.Stat(filepath.Join(wtDir, ".git")) + if err != nil { + t.Fatalf("expected .git in worktree: %v", err) + } + if info.IsDir() { + t.Errorf(".git in linked worktree should be a file, not a directory") + } +}