diff --git a/.nextchanges/bundles/git-folder-metadata.md b/.nextchanges/bundles/git-folder-metadata.md new file mode 100644 index 00000000000..4eac3d8c6b9 --- /dev/null +++ b/.nextchanges/bundles/git-folder-metadata.md @@ -0,0 +1 @@ +Fixed `bundle.git.branch`, `bundle.git.commit`, and `bundle.git.origin_url` being empty for bundles deployed from a workspace Git folder that has Git CLI access. The workspace API does not report git metadata for those folders, so it is now read from the Repos API instead. diff --git a/acceptance/bundle/debug/fetch-repository-info-repos-error/databricks.yml b/acceptance/bundle/debug/fetch-repository-info-repos-error/databricks.yml new file mode 100644 index 00000000000..576d7a9ef25 --- /dev/null +++ b/acceptance/bundle/debug/fetch-repository-info-repos-error/databricks.yml @@ -0,0 +1,2 @@ +bundle: + name: test-bundle diff --git a/acceptance/bundle/debug/fetch-repository-info-repos-error/out.test.toml b/acceptance/bundle/debug/fetch-repository-info-repos-error/out.test.toml new file mode 100644 index 00000000000..0938e678987 --- /dev/null +++ b/acceptance/bundle/debug/fetch-repository-info-repos-error/out.test.toml @@ -0,0 +1,2 @@ +Cloud = false +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] diff --git a/acceptance/bundle/debug/fetch-repository-info-repos-error/output.txt b/acceptance/bundle/debug/fetch-repository-info-repos-error/output.txt new file mode 100644 index 00000000000..bdd6157ad68 --- /dev/null +++ b/acceptance/bundle/debug/fetch-repository-info-repos-error/output.txt @@ -0,0 +1,9 @@ + +>>> [CLI] bundle debug fetch-repository-info --path /Workspace/Users/[USERNAME]/test-folder-[UNIQUE_NAME] +Warn: failed to load git info for repo [NUMID]: Fault injected by test. +{ + "worktree_root": "/Workspace/Users/[USERNAME]/test-folder-[UNIQUE_NAME]", + "current_branch": "", + "latest_commit": "", + "origin_url": "" +} diff --git a/acceptance/bundle/debug/fetch-repository-info-repos-error/script b/acceptance/bundle/debug/fetch-repository-info-repos-error/script new file mode 100644 index 00000000000..0716206028a --- /dev/null +++ b/acceptance/bundle/debug/fetch-repository-info-repos-error/script @@ -0,0 +1,16 @@ +# A Git folder outside /Repos has Git CLI access, so the workspace API reports no +# git metadata for it and it is read from the Repos API instead. Make that read +# fail: the worktree root must still be reported, with the metadata left empty +# rather than failing the command. +# +# Registered before MSYS_NO_PATHCONV is exported, which on Windows would rewrite +# the path Git Bash passes for this helper's own script. +fault.py "GET /api/2.0/repos/*" 403 0 1 + +# Keep Git Bash from rewriting the /Workspace path passed to --path. +export MSYS_NO_PATHCONV=1 + +folder=/Workspace/Users/$CURRENT_USER_NAME/test-folder-$UNIQUE_NAME +$CLI repos create https://github.com/databricks/bundle-examples gitHub --path "$folder" > /dev/null + +trace $CLI bundle debug fetch-repository-info --path "$folder" diff --git a/acceptance/bundle/debug/fetch-repository-info-repos-error/test.toml b/acceptance/bundle/debug/fetch-repository-info-repos-error/test.toml new file mode 100644 index 00000000000..9993005d754 --- /dev/null +++ b/acceptance/bundle/debug/fetch-repository-info-repos-error/test.toml @@ -0,0 +1,5 @@ +# Local only: the failure is injected into the testserver. +Cloud = false + +# This exercises git.FetchRepositoryInfo, which is independent of the deploy engine. +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] diff --git a/acceptance/bundle/debug/fetch-repository-info/databricks.yml b/acceptance/bundle/debug/fetch-repository-info/databricks.yml new file mode 100644 index 00000000000..576d7a9ef25 --- /dev/null +++ b/acceptance/bundle/debug/fetch-repository-info/databricks.yml @@ -0,0 +1,2 @@ +bundle: + name: test-bundle diff --git a/acceptance/bundle/debug/fetch-repository-info/out.test.toml b/acceptance/bundle/debug/fetch-repository-info/out.test.toml new file mode 100644 index 00000000000..c502b28221b --- /dev/null +++ b/acceptance/bundle/debug/fetch-repository-info/out.test.toml @@ -0,0 +1,2 @@ +Cloud = true +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] diff --git a/acceptance/bundle/debug/fetch-repository-info/output.txt b/acceptance/bundle/debug/fetch-repository-info/output.txt new file mode 100644 index 00000000000..2c4a60db972 --- /dev/null +++ b/acceptance/bundle/debug/fetch-repository-info/output.txt @@ -0,0 +1,40 @@ + +=== the root of a Git folder under /Repos + +>>> [CLI] bundle debug fetch-repository-info --path /Workspace/Repos/[USERNAME]/test-repo-[UNIQUE_NAME] +{ + "worktree_root": "/Workspace/Repos/[USERNAME]/test-repo-[UNIQUE_NAME]", + "current_branch": "main", + "latest_commit": "[COMMIT_SHA]", + "origin_url": "https://github.com/databricks/bundle-examples" +} + +=== a subdirectory of it, which resolves to the same root + +>>> [CLI] bundle debug fetch-repository-info --path /Workspace/Repos/[USERNAME]/test-repo-[UNIQUE_NAME]/subdir +{ + "worktree_root": "/Workspace/Repos/[USERNAME]/test-repo-[UNIQUE_NAME]", + "current_branch": "main", + "latest_commit": "[COMMIT_SHA]", + "origin_url": "https://github.com/databricks/bundle-examples" +} + +=== the root of a Git folder outside /Repos + +>>> [CLI] bundle debug fetch-repository-info --path /Workspace/Users/[USERNAME]/test-folder-[UNIQUE_NAME] +{ + "worktree_root": "/Workspace/Users/[USERNAME]/test-folder-[UNIQUE_NAME]", + "current_branch": "main", + "latest_commit": "[COMMIT_SHA]", + "origin_url": "https://github.com/databricks/bundle-examples" +} + +=== a path in no Git folder at all + +>>> [CLI] bundle debug fetch-repository-info --path /Workspace/Users/[USERNAME] +{ + "worktree_root": "", + "current_branch": "", + "latest_commit": "", + "origin_url": "" +} diff --git a/acceptance/bundle/debug/fetch-repository-info/script b/acceptance/bundle/debug/fetch-repository-info/script new file mode 100644 index 00000000000..6f69b821a9b --- /dev/null +++ b/acceptance/bundle/debug/fetch-repository-info/script @@ -0,0 +1,33 @@ +# The fake workspace reports "main", which is this repo's default branch too. +url=https://github.com/databricks/bundle-examples + +# A Git folder under /Repos and one outside it. Once a Git folder has Git CLI +# access the workspace API reports no git metadata for it, so both resolve +# through the Repos API; the resolved metadata must be identical either way. +repo=/Workspace/Repos/$CURRENT_USER_NAME/test-repo-$UNIQUE_NAME +folder=/Workspace/Users/$CURRENT_USER_NAME/test-folder-$UNIQUE_NAME + +$CLI repos create $url gitHub --path "$repo" > /dev/null +$CLI repos create $url gitHub --path "$folder" > /dev/null + +# A directory inside the Git folder, so the subdirectory case below resolves a +# path that exists rather than a missing one. +$CLI workspace mkdirs "$repo/subdir" + +cleanup() { + $CLI repos delete "$repo" 2>>LOG.cleanup + $CLI repos delete "$folder" 2>>LOG.cleanup +} +trap cleanup EXIT + +title "the root of a Git folder under /Repos\n" +trace $CLI bundle debug fetch-repository-info --path "$repo" + +title "a subdirectory of it, which resolves to the same root\n" +trace $CLI bundle debug fetch-repository-info --path "$repo/subdir" + +title "the root of a Git folder outside /Repos\n" +trace $CLI bundle debug fetch-repository-info --path "$folder" + +title "a path in no Git folder at all\n" +trace $CLI bundle debug fetch-repository-info --path "/Workspace/Users/$CURRENT_USER_NAME" diff --git a/acceptance/bundle/debug/fetch-repository-info/test.toml b/acceptance/bundle/debug/fetch-repository-info/test.toml new file mode 100644 index 00000000000..4ad61829cbe --- /dev/null +++ b/acceptance/bundle/debug/fetch-repository-info/test.toml @@ -0,0 +1,13 @@ +Cloud = true + +# This exercises git.FetchRepositoryInfo, which is independent of the deploy engine. +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] + +# Keep Git Bash from rewriting the /Workspace paths passed to --path. +Env.MSYS_NO_PATHCONV = "1" + +# The commit differs between the fake workspace and a real clone of the repo, so +# the golden matches both. +[[Repls]] +Old = '\b[0-9a-f]{40}\b' +New = '[COMMIT_SHA]' diff --git a/acceptance/workspace/repos/create_with_provider/output.txt b/acceptance/workspace/repos/create_with_provider/output.txt index df97be1fcee..caded4c11dc 100644 --- a/acceptance/workspace/repos/create_with_provider/output.txt +++ b/acceptance/workspace/repos/create_with_provider/output.txt @@ -5,6 +5,7 @@ >>> [CLI] repos get [NUMID] -o json { "branch": "main", + "head_commit_id": "0a1b2c3d4e5f6a7b8e7dadd73e50a69d8ba47d8f", "id": [NUMID], "path": "/Repos/me@databricks.com/test-repo", "provider": "gitHub", @@ -15,6 +16,7 @@ >>> [CLI] repos get /Repos/me@databricks.com/test-repo -o json { "branch": "main", + "head_commit_id": "0a1b2c3d4e5f6a7b8e7dadd73e50a69d8ba47d8f", "id": [NUMID], "path": "/Repos/me@databricks.com/test-repo", "provider": "gitHub", diff --git a/acceptance/workspace/repos/create_without_provider/output.txt b/acceptance/workspace/repos/create_without_provider/output.txt index 4a461ec664e..d9e71490e8b 100644 --- a/acceptance/workspace/repos/create_without_provider/output.txt +++ b/acceptance/workspace/repos/create_without_provider/output.txt @@ -2,6 +2,7 @@ >>> [CLI] repos create https://github.com/databricks/databricks-empty-ide-project.git --path /Repos/me@databricks.com/test-repo { "branch": "main", + "head_commit_id": "0a1b2c3d4e5f6a7b8e7dadd73e50a69d8ba47d8f", "id": [NUMID], "path": "/Repos/me@databricks.com/test-repo", "provider": "gitHub", diff --git a/acceptance/workspace/repos/delete_by_path/output.txt b/acceptance/workspace/repos/delete_by_path/output.txt index d522cbd027d..6f8969139bc 100644 --- a/acceptance/workspace/repos/delete_by_path/output.txt +++ b/acceptance/workspace/repos/delete_by_path/output.txt @@ -5,6 +5,7 @@ >>> [CLI] repos get /Repos/me@databricks.com/test-repo -o json { "branch": "main", + "head_commit_id": "0a1b2c3d4e5f6a7b8e7dadd73e50a69d8ba47d8f", "id": [NUMID], "path": "/Repos/me@databricks.com/test-repo", "provider": "gitHub", diff --git a/acceptance/workspace/repos/git_cli_folder/output.txt b/acceptance/workspace/repos/git_cli_folder/output.txt index e22b603c39a..e661587937f 100644 --- a/acceptance/workspace/repos/git_cli_folder/output.txt +++ b/acceptance/workspace/repos/git_cli_folder/output.txt @@ -6,6 +6,7 @@ >>> [CLI] repos get /Workspace/Users/me@databricks.com/test-repo -o json { "branch": "main", + "head_commit_id": "0a1b2c3d4e5f6a7b8e7dadd73e50a69d8ba47d8f", "id": [NUMID], "path": "/Workspace/Users/me@databricks.com/test-repo", "provider": "gitHub", diff --git a/acceptance/workspace/repos/update/output.txt b/acceptance/workspace/repos/update/output.txt index fb5b97bc3a6..af6e590f3fe 100644 --- a/acceptance/workspace/repos/update/output.txt +++ b/acceptance/workspace/repos/update/output.txt @@ -6,6 +6,7 @@ >>> [CLI] repos get [NUMID] -o json { "branch": "update-by-id", + "head_commit_id": "0a1b2c3d4e5f6a7b8e7dadd73e50a69d8ba47d8f", "id": [NUMID], "path": "/Repos/me@databricks.com/test-repo", "provider": "gitHub", @@ -17,6 +18,7 @@ >>> [CLI] repos get [NUMID] -o json { "branch": "update-by-path", + "head_commit_id": "0a1b2c3d4e5f6a7b8e7dadd73e50a69d8ba47d8f", "id": [NUMID], "path": "/Repos/me@databricks.com/test-repo", "provider": "gitHub", diff --git a/cmd/bundle/debug.go b/cmd/bundle/debug.go index c62c75080cc..a07da4c4fda 100644 --- a/cmd/bundle/debug.go +++ b/cmd/bundle/debug.go @@ -18,5 +18,6 @@ func newDebugCommand() *cobra.Command { cmd.AddCommand(debug.NewStatesCommand()) cmd.AddCommand(debug.NewRenderTemplateSchemaCommand()) cmd.AddCommand(debug.NewListTargetsCommand()) + cmd.AddCommand(debug.NewFetchRepositoryInfoCommand()) return cmd } diff --git a/cmd/bundle/debug/fetch_repository_info.go b/cmd/bundle/debug/fetch_repository_info.go new file mode 100644 index 00000000000..f735d215c60 --- /dev/null +++ b/cmd/bundle/debug/fetch_repository_info.go @@ -0,0 +1,63 @@ +package debug + +import ( + "encoding/json" + + "github.com/databricks/cli/cmd/root" + "github.com/databricks/cli/libs/cmdctx" + "github.com/databricks/cli/libs/git" + "github.com/spf13/cobra" +) + +// repositoryInfoOutput mirrors git.RepositoryInfo, which has no JSON tags. +type repositoryInfoOutput struct { + WorktreeRoot string `json:"worktree_root"` + CurrentBranch string `json:"current_branch"` + LatestCommit string `json:"latest_commit"` + OriginURL string `json:"origin_url"` +} + +// NewFetchRepositoryInfoCommand returns a command that reports what +// [git.FetchRepositoryInfoAPI] resolves for a path. It exists so that function +// can be exercised through the CLI on both a fake and a real workspace; nothing +// in the product calls it. +// +// It always reads the workspace API, which [git.FetchRepositoryInfo] only does on +// a Databricks Runtime. That cannot be detected off-cluster, and reading .git is +// covered by the TestFetchRepositoryInfoDotGit integration tests. +func NewFetchRepositoryInfoCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "fetch-repository-info", + Short: "Report the git metadata the workspace API resolves for a path", + Args: root.NoArgs, + Hidden: true, + } + + var path string + cmd.Flags().StringVar(&path, "path", ".", "Path to resolve git metadata for") + + cmd.PreRunE = root.MustWorkspaceClient + cmd.RunE = func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + + info, err := git.FetchRepositoryInfoAPI(ctx, path, cmdctx.WorkspaceClient(ctx)) + if err != nil { + return err + } + + buf, err := json.MarshalIndent(repositoryInfoOutput{ + WorktreeRoot: info.WorktreeRoot, + CurrentBranch: info.CurrentBranch, + LatestCommit: info.LatestCommit, + OriginURL: info.OriginURL, + }, "", " ") + if err != nil { + return err + } + + _, err = cmd.OutOrStdout().Write(append(buf, '\n')) + return err + } + + return cmd +} diff --git a/integration/libs/git/git_fetch_test.go b/integration/libs/git/git_fetch_test.go index 094389284cb..95ca99eec30 100644 --- a/integration/libs/git/git_fetch_test.go +++ b/integration/libs/git/git_fetch_test.go @@ -9,7 +9,6 @@ import ( "testing" "github.com/databricks/cli/integration/internal/acc" - "github.com/databricks/cli/internal/testutil" "github.com/databricks/cli/libs/dbr" "github.com/databricks/cli/libs/git" "github.com/stretchr/testify/assert" @@ -48,13 +47,6 @@ func ensureWorkspacePrefix(root string) string { } func TestFetchRepositoryInfoAPI_FromRepo(t *testing.T) { - // On GCP the Repos API returns no branch, commit, or origin URL for a - // freshly-cloned repo, so FetchRepositoryInfo comes back empty and the - // assertions in assertFullGitInfo fail. - if testutil.GetCloud(t) == testutil.GCP { - t.Skip("Skipping on GCP: Repos API does not return git metadata") - } - ctx, wt := acc.WorkspaceTest(t) targetPath := ensureWorkspacePrefix(acc.TemporaryRepo(wt, examplesRepoUrl)) diff --git a/libs/git/info.go b/libs/git/info.go index 6e31d68219c..5d1d810dbc0 100644 --- a/libs/git/info.go +++ b/libs/git/info.go @@ -16,6 +16,7 @@ import ( "github.com/databricks/databricks-sdk-go" "github.com/databricks/databricks-sdk-go/apierr" "github.com/databricks/databricks-sdk-go/client" + "github.com/databricks/databricks-sdk-go/service/workspace" ) type RepositoryInfo struct { @@ -31,6 +32,7 @@ type RepositoryInfo struct { type gitInfo struct { Branch string `json:"branch"` HeadCommitID string `json:"head_commit_id"` + ID int64 `json:"id"` Path string `json:"path"` URL string `json:"url"` } @@ -50,7 +52,7 @@ func FetchRepositoryInfo(ctx context.Context, path string, w *databricks.Workspa var info RepositoryInfo var err error if strings.HasPrefix(path, "/Workspace/") && dbr.RunsOnRuntime(ctx) { - info, err = fetchRepositoryInfoAPI(ctx, path, w) + info, err = FetchRepositoryInfoAPI(ctx, path, w) } else { info, err = fetchRepositoryInfoDotGit(ctx, path) } @@ -65,7 +67,14 @@ func FetchRepositoryInfo(ctx context.Context, path string, w *databricks.Workspa return info, err } -func fetchRepositoryInfoAPI(ctx context.Context, path string, w *databricks.WorkspaceClient) (RepositoryInfo, error) { +// FetchRepositoryInfoAPI reads the metadata from the workspace API, which is what +// FetchRepositoryInfo does on a Databricks Runtime. Exported so that +// `bundle debug fetch-repository-info --workspace-api` can reach this path +// off-runtime; the product calls it through FetchRepositoryInfo. +// +// A path that does not exist is reported as fs.ErrNotExist; callers that treat +// that as "no repository here" must normalize it the way FetchRepositoryInfo does. +func FetchRepositoryInfoAPI(ctx context.Context, path string, w *databricks.WorkspaceClient) (RepositoryInfo, error) { result := RepositoryInfo{} apiClient, err := client.New(w.Config) @@ -108,6 +117,21 @@ func fetchRepositoryInfoAPI(ctx context.Context, path string, w *databricks.Work result.LatestCommit = gi.HeadCommitID result.CurrentBranch = gi.Branch result.WorktreeRoot = fixedPath + + // A Git folder with Git CLI access does not store the git metadata on the + // workspace object, so get-status returns only the id and path for it. The + // Repos API still has the metadata, and git_info.id identifies the Git + // folder root even when the queried path is a subdirectory of it. + if gi.ID != 0 && gi.Branch == "" && gi.HeadCommitID == "" && gi.URL == "" { + repo, err := w.Repos.Get(ctx, workspace.GetRepoRequest{RepoId: gi.ID}) + if err != nil { + log.Warnf(ctx, "failed to load git info for repo %d: %s", gi.ID, err) + } else { + result.OriginURL = repo.Url + result.LatestCommit = repo.HeadCommitId + result.CurrentBranch = repo.Branch + } + } } else { log.Infof(ctx, "Failed to load git info from %s", apiEndpoint) } diff --git a/libs/testserver/fake_workspace.go b/libs/testserver/fake_workspace.go index 445417b3ff1..1caec84d1e4 100644 --- a/libs/testserver/fake_workspace.go +++ b/libs/testserver/fake_workspace.go @@ -9,6 +9,7 @@ import ( "path" "path/filepath" "slices" + "strconv" "strings" "sync" "time" @@ -441,26 +442,96 @@ func (s *FakeWorkspace) CurrentUser() iam.User { } } -func (s *FakeWorkspace) WorkspaceGetStatus(requestPath string) Response { +// gitInfoBlock is the git_info block get-status adds for return_git_info=true. +// workspace.ObjectInfo does not model it (the field is undocumented), so it is +// merged into the response separately, see withGitInfo. +// +// A Git folder that has Git CLI access does not store the git metadata on the +// workspace object, so Branch, HeadCommitID and URL are empty for one; only a +// standard Git folder reports them. +type gitInfoBlock struct { + Branch string `json:"branch,omitempty"` + HeadCommitID string `json:"head_commit_id,omitempty"` + ID int64 `json:"id"` + Path string `json:"path"` + URL string `json:"url,omitempty"` +} + +// withGitInfo returns info as an object with a git_info block added. +// +// It cannot be a struct embedding workspace.ObjectInfo: ObjectInfo declares +// MarshalJSON, which gets promoted to the embedding struct, so the outer +// git_info field would be dropped from the output without any error. The +// intermediate map holds json.RawMessage rather than any, so that large ids do +// not lose precision by passing through float64. +func withGitInfo(info workspace.ObjectInfo, gi gitInfoBlock) (any, error) { + infoJSON, err := json.Marshal(info) + if err != nil { + return nil, err + } + out := map[string]json.RawMessage{} + if err := json.Unmarshal(infoJSON, &out); err != nil { + return nil, err + } + giJSON, err := json.Marshal(gi) + if err != nil { + return nil, err + } + out["git_info"] = giJSON + return out, nil +} + +// gitFolderFor returns the Git folder containing objectPath, which is the +// longest registered Git folder path that is a prefix of it. get-status reports +// the containing folder's metadata for paths inside a Git folder, not just for +// its root. +func (s *FakeWorkspace) gitFolderFor(objectPath string) (workspace.RepoInfo, bool) { + longest := "" + for repoPath := range s.repoIdByPath { + if objectPath != repoPath && !strings.HasPrefix(objectPath, repoPath+"/") { + continue + } + if len(repoPath) > len(longest) { + longest = repoPath + } + } + if longest == "" { + return workspace.RepoInfo{}, false + } + return s.Repos[strconv.FormatInt(s.repoIdByPath[longest], 10)], true +} + +// isGitCliFolder reports whether a Git folder at this path has Git CLI access. +// Those are materialized as plain DIRECTORY nodes outside /Repos, while a +// standard Git folder under /Repos keeps the REPO object type. The /Workspace +// mount prefix is optional on a request, so it is not part of the distinction. +func isGitCliFolder(repoPath string) bool { + return !strings.HasPrefix(strings.TrimPrefix(repoPath, "/Workspace"), "/Repos/") +} + +func (s *FakeWorkspace) WorkspaceGetStatus(requestPath string, returnGitInfo bool) Response { defer s.LockUnlock()() // The real API collapses duplicate slashes, so look up the cleaned path. cleaned := path.Clean(requestPath) var info workspace.ObjectInfo - if dirInfo, ok := s.directories[cleaned]; ok { - info = dirInfo - } else if entry, ok := s.files[cleaned]; ok { - info = entry.Info - } else if repoId, ok := s.repoIdByPath[cleaned]; ok { + // A Git folder root is reported as the repo, before any directory entry for + // the same path: mkdirs of a path inside a Git folder seeds its ancestors, + // which would otherwise shadow the root and report the wrong object id. + if repoId, ok := s.repoIdByPath[cleaned]; ok { // Control-plane repos (under /Repos) report the REPO object type, while // Git-CLI-enabled folders elsewhere are materialized as plain DIRECTORY // nodes. Both resolve to a valid repo ID via the repos API. objectType := workspace.ObjectTypeRepo - if !strings.HasPrefix(cleaned, "/Repos/") { + if isGitCliFolder(cleaned) { objectType = workspace.ObjectTypeDirectory } info = workspace.ObjectInfo{ObjectType: objectType, Path: cleaned, ObjectId: repoId} + } else if dirInfo, ok := s.directories[cleaned]; ok { + info = dirInfo + } else if entry, ok := s.files[cleaned]; ok { + info = entry.Info } else { // Match the real Workspace API wording, which echoes the requested path. return Response{ @@ -477,6 +548,29 @@ func (s *FakeWorkspace) WorkspaceGetStatus(requestPath string) Response { info.Path = strings.TrimPrefix(info.Path, "/Workspace") } + if returnGitInfo { + if repo, ok := s.gitFolderFor(cleaned); ok { + // The real API reports the Git folder root without the /Workspace mount + // prefix, whichever spelling the folder was created with. + gi := gitInfoBlock{ID: repo.Id, Path: strings.TrimPrefix(repo.Path, "/Workspace")} + if isGitCliFolder(repo.Path) { + info.DirectoryInfo = &workspace.DirectoryInfo{IsGitFolder: true} + } else { + gi.Branch = repo.Branch + gi.HeadCommitID = repo.HeadCommitId + gi.URL = repo.Url + } + body, err := withGitInfo(info, gi) + if err != nil { + return Response{ + StatusCode: 500, + Body: fmt.Sprintf("internal error: %s", err), + } + } + return Response{Body: body} + } + } + return Response{Body: info} } diff --git a/libs/testserver/handlers.go b/libs/testserver/handlers.go index 082b771d1ea..099230ad94e 100644 --- a/libs/testserver/handlers.go +++ b/libs/testserver/handlers.go @@ -86,7 +86,8 @@ func AddDefaultHandlers(server *Server) { server.Handle("GET", "/api/2.0/workspace/get-status", func(req Request) any { path := req.URL.Query().Get("path") - return req.Workspace.WorkspaceGetStatus(path) + returnGitInfo := req.URL.Query().Get("return_git_info") == "true" + return req.Workspace.WorkspaceGetStatus(path, returnGitInfo) }) server.Handle("GET", "/api/2.0/workspace/list", func(req Request) any { diff --git a/libs/testserver/repos.go b/libs/testserver/repos.go index 7f1b959a096..4686ba3103a 100644 --- a/libs/testserver/repos.go +++ b/libs/testserver/repos.go @@ -9,6 +9,12 @@ import ( "github.com/databricks/databricks-sdk-go/service/workspace" ) +// fakeHeadCommitID is the commit the fake reports a clone is checked out to. +// Nothing is cloned here, so the remote's real HEAD is unknowable. The counting +// prefix marks it as synthetic, so it is not mistaken for a real sha; it avoids +// runs of three digits, which the test replacements would rewrite to [NUMID]. +const fakeHeadCommitID = "0a1b2c3d4e5f6a7b8e7dadd73e50a69d8ba47d8f" + func (s *FakeWorkspace) ReposCreate(req Request) Response { defer s.LockUnlock()() @@ -25,11 +31,12 @@ func (s *FakeWorkspace) ReposCreate(req Request) Response { id := strconv.FormatInt(repoId, 10) repoInfo := workspace.RepoInfo{ - Id: repoId, - Path: repoReq.Path, - Provider: repoReq.Provider, - Url: repoReq.Url, - Branch: "main", + Id: repoId, + Path: repoReq.Path, + Provider: repoReq.Provider, + Url: repoReq.Url, + Branch: "main", + HeadCommitId: fakeHeadCommitID, } s.Repos[id] = repoInfo diff --git a/libs/testserver/workspace_test.go b/libs/testserver/workspace_test.go index 3ca5b148c2e..5e4753246cf 100644 --- a/libs/testserver/workspace_test.go +++ b/libs/testserver/workspace_test.go @@ -1,6 +1,7 @@ package testserver_test import ( + "encoding/json" "net/http" "strings" "testing" @@ -63,3 +64,80 @@ func TestWorkspaceMkdirsRecursive(t *testing.T) { assert.Equal(t, 200, getStatus(t, server.URL, dir), dir) } } + +func createRepo(t *testing.T, baseURL, path string) { + t.Helper() + body := `{"url":"https://github.com/databricks/cli","provider":"gitHub","path":"` + path + `"}` + req, _ := http.NewRequest(http.MethodPost, baseURL+"/api/2.0/repos", strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer test-token") + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, 200, resp.StatusCode) +} + +func getStatusBody(t *testing.T, baseURL, path string, returnGitInfo bool) map[string]any { + t.Helper() + url := baseURL + "/api/2.0/workspace/get-status?path=" + path + if returnGitInfo { + url += "&return_git_info=true" + } + req, _ := http.NewRequest(http.MethodGet, url, nil) + req.Header.Set("Authorization", "Bearer test-token") + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, 200, resp.StatusCode) + + var out map[string]any + require.NoError(t, json.NewDecoder(resp.Body).Decode(&out)) + return out +} + +// How a Git folder is described on the wire: the object type, directory_info and +// the presence of git_info. The acceptance tests read the resolved metadata +// rather than the response, so these fields are only pinned here. + +// A standard Git folder keeps the REPO object type and reports the full metadata, +// and only when it is asked for. +func TestWorkspaceGetStatusGitInfoForRepo(t *testing.T) { + server := testserver.New(t) + testserver.AddDefaultHandlers(server) + + createRepo(t, server.URL, "/Repos/me/myrepo") + + assert.NotContains(t, getStatusBody(t, server.URL, "/Repos/me/myrepo", false), "git_info") + + body := getStatusBody(t, server.URL, "/Repos/me/myrepo", true) + assert.Equal(t, "REPO", body["object_type"]) + assert.NotContains(t, body, "directory_info") + + gitInfo, ok := body["git_info"].(map[string]any) + require.True(t, ok, "expected git_info in %v", body) + assert.Equal(t, "main", gitInfo["branch"]) + assert.NotEmpty(t, gitInfo["head_commit_id"]) + assert.Equal(t, "https://github.com/databricks/cli", gitInfo["url"]) +} + +// A Git folder with Git CLI access is a DIRECTORY that marks itself through +// directory_info, and reports only the id and path. +func TestWorkspaceGetStatusGitInfoForGitCliFolder(t *testing.T) { + server := testserver.New(t) + testserver.AddDefaultHandlers(server) + + createRepo(t, server.URL, "/Workspace/Users/me/gitfolder") + body := getStatusBody(t, server.URL, "/Workspace/Users/me/gitfolder", true) + + assert.Equal(t, "DIRECTORY", body["object_type"]) + dirInfo, ok := body["directory_info"].(map[string]any) + require.True(t, ok, "expected directory_info in %v", body) + assert.Equal(t, true, dirInfo["is_git_folder"]) + + gitInfo, ok := body["git_info"].(map[string]any) + require.True(t, ok, "expected git_info in %v", body) + assert.Equal(t, "/Users/me/gitfolder", gitInfo["path"]) + assert.NotEmpty(t, gitInfo["id"]) + assert.NotContains(t, gitInfo, "branch") + assert.NotContains(t, gitInfo, "head_commit_id") + assert.NotContains(t, gitInfo, "url") +}