From 9e48a71dbe502408f3678f6bfefd9ee87490c3cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Almeida?= <57680069+zeval@users.noreply.github.com> Date: Sat, 19 Sep 2026 15:43:12 +0100 Subject: [PATCH] feat: add per-task remote repository checkout options (#3817) --- .../agentctl/client_workspace_sources.go | 13 +- .../agent/runtime/lifecycle/env_preparer.go | 3 + .../lifecycle/env_preparer_local_test.go | 2 +- .../lifecycle/env_preparer_worktree.go | 5 +- .../env_preparer_worktree_env_test.go | 23 ++ .../runtime/lifecycle/executor_docker.go | 17 ++ .../runtime/lifecycle/manager_capabilities.go | 6 + .../agent/runtime/lifecycle/manager_launch.go | 6 + .../lifecycle/repository_checkout_options.go | 107 +++++++ ...ository_checkout_options_container_test.go | 94 +++++++ .../repository_checkout_options_test.go | 167 +++++++++++ .../internal/agent/runtime/lifecycle/types.go | 3 + .../lifecycle/workspace_materialization.go | 4 +- .../api/workspace_checkout_options_test.go | 45 +++ .../server/api/workspace_materialize.go | 92 ++++-- apps/backend/internal/backendapp/adapters.go | 2 + apps/backend/internal/backendapp/main.go | 7 + apps/backend/internal/gitcheckout/identity.go | 55 ++++ apps/backend/internal/gitcheckout/sparse.go | 67 +++++ .../orchestrator/executor/executor.go | 2 + .../executor/executor_environment_reuse.go | 1 + .../orchestrator/executor/executor_execute.go | 2 + .../orchestrator/executor/executor_resume.go | 24 +- .../executor/repository_checkout_options.go | 40 +++ .../repository_checkout_options_test.go | 30 ++ .../internal/repoclone/checkout_options.go | 151 ++++++++++ apps/backend/internal/repoclone/clone.go | 13 + .../repoclone/clone_checkout_options_test.go | 156 ++++++++++ .../internal/repoclone/remote_state.go | 11 + apps/backend/internal/task/dto/dto.go | 30 +- apps/backend/internal/task/dto/requests.go | 33 +-- .../repository_checkout_capabilities.go | 25 ++ .../repository_checkout_options_test.go | 52 ++++ .../repository_checkout_profiles_test.go | 45 +++ .../task/handlers/repository_handlers.go | 1 + .../internal/task/handlers/task_handlers.go | 1 + .../task/handlers/task_http_handlers.go | 106 +++---- .../task/handlers/task_ws_handlers.go | 73 ++--- .../task/handlers/task_ws_handlers_test.go | 12 + apps/backend/internal/task/models/models.go | 17 +- .../models/repository_checkout_options.go | 96 +++++++ .../repository_checkout_options_test.go | 56 ++++ .../repository_checkout_capabilities.go | 113 ++++++++ .../service/repository_checkout_options.go | 96 +++++++ .../repository_checkout_options_test.go | 131 +++++++++ .../task/service/repository_selection.go | 1 + apps/backend/internal/task/service/service.go | 1 + .../internal/task/service/service_requests.go | 33 +-- .../internal/task/service/service_tasks.go | 21 +- .../internal/worktree/checkout_options.go | 79 ++++++ apps/backend/internal/worktree/git_command.go | 4 +- apps/backend/internal/worktree/gitcrypt.go | 19 +- .../worktree/manager_checkout_options_test.go | 177 ++++++++++++ apps/backend/internal/worktree/manager_git.go | 6 +- .../internal/worktree/manager_lifecycle.go | 68 ++--- apps/backend/internal/worktree/submodule.go | 35 ++- apps/backend/internal/worktree/worktree.go | 4 + apps/backend/pkg/api/v1/task.go | 31 +- ...ask-create-dialog-checkout-options.test.ts | 50 ++++ .../task-create-dialog-checkout-options.ts | 32 +++ .../components/task-create-dialog-helpers.ts | 6 +- ...sk-create-dialog-remote-repo-chip.test.tsx | 11 + .../task-create-dialog-remote-repo-chip.tsx | 29 +- .../task-create-dialog-remote-repo-chips.tsx | 3 + .../task-create-dialog-repositories-state.ts | 3 +- ...reate-dialog-repository-options-fields.tsx | 165 +++++++++++ .../task-create-dialog-repository-options.tsx | 266 ++++++++++++++++++ .../components/task-create-dialog-types.ts | 2 + .../mobile-remote-repository-options.spec.ts | 70 +++++ .../task/remote-repository-options.spec.ts | 98 +++++++ ...e-repository-checkout-capabilities.test.ts | 64 +++++ .../use-repository-checkout-capabilities.ts | 39 +++ apps/web/lib/api/domains/kanban-api.ts | 3 + .../api/domains/repository-checkout-api.ts | 24 ++ apps/web/lib/types/http-workspace-sources.ts | 2 + .../lib/types/repository-checkout-options.ts | 14 + apps/web/scripts/lib/zh-hant-overrides.json | 3 +- apps/web/src/locales/en/task.json | 26 ++ apps/web/src/locales/pseudo/task.json | 26 ++ apps/web/src/locales/pt-pt/task.json | 26 ++ apps/web/src/locales/zh-cn/task.json | 26 ++ apps/web/src/locales/zh-hk/task.json | 26 ++ apps/web/src/locales/zh-tw/task.json | 26 ++ docs/plans/remote-repository-options/plan.md | 232 +++++++++++++++ .../task-01-policy-contract.md | 74 +++++ .../task-02-host-preparation.md | 73 +++++ .../task-03-remote-preparation.md | 78 +++++ .../task-04-repository-gear.md | 184 ++++++++++++ docs/public/tasks-and-workflows.md | 29 ++ .../requirements/remote-repository-options.md | 66 +++++ .../remote-repository-options.md | 226 +++++++++++++++ 91 files changed, 4284 insertions(+), 232 deletions(-) create mode 100644 apps/backend/internal/agent/runtime/lifecycle/repository_checkout_options.go create mode 100644 apps/backend/internal/agent/runtime/lifecycle/repository_checkout_options_container_test.go create mode 100644 apps/backend/internal/agent/runtime/lifecycle/repository_checkout_options_test.go create mode 100644 apps/backend/internal/agentctl/server/api/workspace_checkout_options_test.go create mode 100644 apps/backend/internal/gitcheckout/identity.go create mode 100644 apps/backend/internal/gitcheckout/sparse.go create mode 100644 apps/backend/internal/orchestrator/executor/repository_checkout_options.go create mode 100644 apps/backend/internal/orchestrator/executor/repository_checkout_options_test.go create mode 100644 apps/backend/internal/repoclone/checkout_options.go create mode 100644 apps/backend/internal/repoclone/clone_checkout_options_test.go create mode 100644 apps/backend/internal/task/handlers/repository_checkout_capabilities.go create mode 100644 apps/backend/internal/task/handlers/repository_checkout_options_test.go create mode 100644 apps/backend/internal/task/handlers/repository_checkout_profiles_test.go create mode 100644 apps/backend/internal/task/models/repository_checkout_options.go create mode 100644 apps/backend/internal/task/models/repository_checkout_options_test.go create mode 100644 apps/backend/internal/task/service/repository_checkout_capabilities.go create mode 100644 apps/backend/internal/task/service/repository_checkout_options.go create mode 100644 apps/backend/internal/task/service/repository_checkout_options_test.go create mode 100644 apps/backend/internal/worktree/checkout_options.go create mode 100644 apps/backend/internal/worktree/manager_checkout_options_test.go create mode 100644 apps/web/components/task-create-dialog-checkout-options.test.ts create mode 100644 apps/web/components/task-create-dialog-checkout-options.ts create mode 100644 apps/web/components/task-create-dialog-repository-options-fields.tsx create mode 100644 apps/web/components/task-create-dialog-repository-options.tsx create mode 100644 apps/web/e2e/tests/task/mobile-remote-repository-options.spec.ts create mode 100644 apps/web/e2e/tests/task/remote-repository-options.spec.ts create mode 100644 apps/web/hooks/domains/workspace/use-repository-checkout-capabilities.test.ts create mode 100644 apps/web/hooks/domains/workspace/use-repository-checkout-capabilities.ts create mode 100644 apps/web/lib/api/domains/repository-checkout-api.ts create mode 100644 apps/web/lib/types/repository-checkout-options.ts create mode 100644 docs/plans/remote-repository-options/plan.md create mode 100644 docs/plans/remote-repository-options/task-01-policy-contract.md create mode 100644 docs/plans/remote-repository-options/task-02-host-preparation.md create mode 100644 docs/plans/remote-repository-options/task-03-remote-preparation.md create mode 100644 docs/plans/remote-repository-options/task-04-repository-gear.md create mode 100644 docs/specs/tasks/requirements/remote-repository-options.md create mode 100644 docs/specs/tasks/system-design/remote-repository-options.md diff --git a/apps/backend/internal/agent/runtime/agentctl/client_workspace_sources.go b/apps/backend/internal/agent/runtime/agentctl/client_workspace_sources.go index c49eabc39a..3af1adb0d1 100644 --- a/apps/backend/internal/agent/runtime/agentctl/client_workspace_sources.go +++ b/apps/backend/internal/agent/runtime/agentctl/client_workspace_sources.go @@ -14,12 +14,13 @@ import ( // request accepted by agentctl. It intentionally has no credential field; // lifecycle wiring supplies Git credentials separately when that is needed. type MaterializeRepositoryRequest struct { - RepositoryURL string `json:"repository_url"` - Destination string `json:"destination"` - BaseBranch string `json:"base_branch"` - CheckoutBranch string `json:"checkout_branch,omitempty"` - RemoteContribution *models.RemoteContribution `json:"remote_contribution,omitempty"` - ContributionDestination *models.ContributionDestination `json:"contribution_destination,omitempty"` + CheckoutOptions *models.RepositoryCheckoutOptions `json:"checkout_options,omitempty"` + RepositoryURL string `json:"repository_url"` + Destination string `json:"destination"` + BaseBranch string `json:"base_branch"` + CheckoutBranch string `json:"checkout_branch,omitempty"` + RemoteContribution *models.RemoteContribution `json:"remote_contribution,omitempty"` + ContributionDestination *models.ContributionDestination `json:"contribution_destination,omitempty"` } // MaterializeRepositoryResponse reports the adopted workspace subdirectory. diff --git a/apps/backend/internal/agent/runtime/lifecycle/env_preparer.go b/apps/backend/internal/agent/runtime/lifecycle/env_preparer.go index 9a5487f621..e192122685 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/env_preparer.go +++ b/apps/backend/internal/agent/runtime/lifecycle/env_preparer.go @@ -78,6 +78,7 @@ type RepoPrepareSpec struct { CheckoutBranch string PRNumber int // GitHub PR number when CheckoutBranch is a PR head; enables refs/pull//head fetch for fork PRs. RemoteContribution *models.RemoteContribution + CheckoutOptions *models.RepositoryCheckoutOptions WorktreeID string // WorkspaceReuseRequired makes preparation attach to the exact canonical // environment. It forbids worktree creation/recreation and all repository @@ -131,6 +132,7 @@ type EnvPrepareRequest struct { CheckoutBranch string PRNumber int // GitHub PR number when CheckoutBranch is a PR head; enables refs/pull//head fetch for fork PRs. RemoteContribution *models.RemoteContribution + CheckoutOptions *models.RepositoryCheckoutOptions ContributionDestination *models.ContributionDestination WorktreeID string WorktreeBranch string @@ -192,6 +194,7 @@ func (r *EnvPrepareRequest) RepoSpecs() []RepoPrepareSpec { CheckoutBranch: r.CheckoutBranch, PRNumber: r.PRNumber, RemoteContribution: r.RemoteContribution, + CheckoutOptions: r.CheckoutOptions, WorktreeID: r.WorktreeID, WorkspaceReuseRequired: r.WorkspaceReuseRequired, AllowBranchReplacement: r.AllowBranchReplacement, diff --git a/apps/backend/internal/agent/runtime/lifecycle/env_preparer_local_test.go b/apps/backend/internal/agent/runtime/lifecycle/env_preparer_local_test.go index b1b5242906..888780ba7a 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/env_preparer_local_test.go +++ b/apps/backend/internal/agent/runtime/lifecycle/env_preparer_local_test.go @@ -192,7 +192,7 @@ func isolateGitEnv(t *testing.T) { t.Setenv("GIT_CONFIG_NOSYSTEM", "1") // Unset vars set by git hooks that would redirect commands to the host repo. // Cannot use t.Setenv("", "") because GIT_DIR="" makes git fail differently. - for _, key := range []string{"GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE"} { + for _, key := range []string{"GIT_DIR", "GIT_WORK_TREE", "GIT_COMMON_DIR", "GIT_INDEX_FILE", "GIT_OBJECT_DIRECTORY", "GIT_ALTERNATE_OBJECT_DIRECTORIES"} { if val, ok := os.LookupEnv(key); ok { _ = os.Unsetenv(key) t.Cleanup(func() { _ = os.Setenv(key, val) }) diff --git a/apps/backend/internal/agent/runtime/lifecycle/env_preparer_worktree.go b/apps/backend/internal/agent/runtime/lifecycle/env_preparer_worktree.go index 687063ca30..a4c28ef98a 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/env_preparer_worktree.go +++ b/apps/backend/internal/agent/runtime/lifecycle/env_preparer_worktree.go @@ -305,6 +305,7 @@ func buildWorktreeCreateRequest(req *EnvPrepareRequest) worktree.CreateRequest { CheckoutBranch: req.CheckoutBranch, PRNumber: req.PRNumber, RemoteContribution: req.RemoteContribution, + CheckoutOptions: req.CheckoutOptions, WorktreeBranchPrefix: req.WorktreeBranchPrefix, WorktreeBranchTemplate: req.WorktreeBranchTemplate, WorktreeBranchTicket: req.WorktreeBranchTicket, @@ -324,7 +325,8 @@ func buildWorktreeCreateRequest(req *EnvPrepareRequest) worktree.CreateRequest { // Repository setup scripts receive profile and repository environment, // but never the managed Git credential broker capabilities used to // prepare the agent runtime. - ScriptEnv: setupScriptEnvironment(req.Env), + ScriptEnv: setupScriptEnvironment(req.Env), + CheckoutEnv: checkoutCredentialEnvironment(req.Env), } } @@ -582,6 +584,7 @@ func (p *WorktreePreparer) prepareOneRepo( subReq.CheckoutBranch = spec.CheckoutBranch subReq.PRNumber = spec.PRNumber subReq.RemoteContribution = spec.RemoteContribution + subReq.CheckoutOptions = spec.CheckoutOptions subReq.ContributionDestination = spec.ContributionDestination subReq.WorktreeID = spec.WorktreeID subReq.WorkspaceReuseRequired = req.WorkspaceReuseRequired || spec.WorkspaceReuseRequired diff --git a/apps/backend/internal/agent/runtime/lifecycle/env_preparer_worktree_env_test.go b/apps/backend/internal/agent/runtime/lifecycle/env_preparer_worktree_env_test.go index 8a45b42d56..a53f42cf51 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/env_preparer_worktree_env_test.go +++ b/apps/backend/internal/agent/runtime/lifecycle/env_preparer_worktree_env_test.go @@ -99,3 +99,26 @@ func TestBuildWorktreeCreateRequestAllowsExplicitBranchReplacement(t *testing.T) t.Fatal("explicit branch replacement must not use attach-only ReuseRequired") } } + +func TestBuildWorktreeCreateRequestScopesCheckoutCredentials(t *testing.T) { + env := map[string]string{ + "GIT_DIR": "/profile/git", "PROFILE_SECRET": "not-for-git", + githubauth.CredentialBrokerURLEnv: "https://broker.example/resolve", + githubauth.CredentialLeaseEnv: "lease", githubauth.CredentialHelperPathEnv: "/opt/agentctl", + "GIT_CONFIG_COUNT": "4", + "GIT_CONFIG_KEY_0": "core.hooksPath", "GIT_CONFIG_VALUE_0": "/profile/hooks", + "GIT_CONFIG_KEY_1": "credential.https://github.com.helper", "GIT_CONFIG_VALUE_1": "", + "GIT_CONFIG_KEY_2": "credential.https://github.com.helper", "GIT_CONFIG_VALUE_2": githubauth.ManagedGitCredentialHelper, + "GIT_CONFIG_KEY_3": "credential.https://github.com.useHttpPath", "GIT_CONFIG_VALUE_3": "true", + } + got := buildWorktreeCreateRequest(&EnvPrepareRequest{Env: env}).CheckoutEnv + if got["GIT_DIR"] != "" || got["PROFILE_SECRET"] != "" { + t.Fatal("profile environment reached checkout") + } + if got[githubauth.CredentialLeaseEnv] != "lease" || got[githubauth.CredentialHelperPathEnv] != "/opt/agentctl" { + t.Fatal("managed authentication lost") + } + if got["GIT_CONFIG_COUNT"] != "3" || got["GIT_CONFIG_KEY_0"] != "credential.https://github.com.helper" || got["GIT_CONFIG_VALUE_1"] != githubauth.ManagedGitCredentialHelper || got["GIT_CONFIG_KEY_2"] != "credential.https://github.com.useHttpPath" { + t.Fatalf("checkout config = %#v", got) + } +} diff --git a/apps/backend/internal/agent/runtime/lifecycle/executor_docker.go b/apps/backend/internal/agent/runtime/lifecycle/executor_docker.go index 9d3cbda663..6b0eea815d 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/executor_docker.go +++ b/apps/backend/internal/agent/runtime/lifecycle/executor_docker.go @@ -822,6 +822,18 @@ func (r *DockerExecutor) resolvePrepareScript(req *ExecutorCreateRequest) (strin if script == "" { return "", nil } + options, err := primaryCheckoutOptions(req.Metadata) + if err != nil { + return "", err + } + script, err = checkoutOptionsPrepareScript(script, options) + if err != nil { + return "", err + } + deferSetup := options != nil && len(options.SparseDirectories) > 0 + if deferSetup { + script = strings.Replace(script, "{{repository.setup_script}}", "", 1) + } script = withBranchCheckout(req, script) if binding, ok := req.RemoteContributions[""]; ok { contributionScript, err := scriptengine.RemoteContributionSetupScript(&binding) @@ -830,6 +842,7 @@ func (r *DockerExecutor) resolvePrepareScript(req *ExecutorCreateRequest) (strin } script += contributionScript } + script += checkoutOptionsValidationScript(options) if destination, ok := req.ContributionDestinations[""]; ok { destinationScript, err := scriptengine.ContributionDestinationSetupScript(&destination) if err != nil { @@ -838,6 +851,10 @@ func (r *DockerExecutor) resolvePrepareScript(req *ExecutorCreateRequest) (strin script += destinationScript } + if deferSetup { + script += "\n" + selectedCheckoutCredentialScrubScript(req.Metadata) + "\n{{repository.setup_script}}\n" + } + resolver := scriptengine.NewResolver(). WithProvider(scriptengine.WorkspaceProvider(dockerWorkspacePath)). WithProvider(scriptengine.GitIdentityProvider(req.Metadata)). diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_capabilities.go b/apps/backend/internal/agent/runtime/lifecycle/manager_capabilities.go index 50868549a9..9244e9432a 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager_capabilities.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_capabilities.go @@ -17,3 +17,9 @@ func (m *Manager) ShouldApplyPreferredShell(executorType string) bool { } return backend.ShouldApplyPreferredShell() } + +// SupportsRepositoryCheckoutOptions advertises only managed preparation paths. +func (m *Manager) SupportsRepositoryCheckoutOptions(executorType, prepareScript string) (bool, bool) { + supported := (executorType == "worktree" || executorType == "local_docker") && (prepareScript == "" || prepareScript == DefaultPrepareScript(executorType)) + return supported, supported +} diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_launch.go b/apps/backend/internal/agent/runtime/lifecycle/manager_launch.go index d70a2fee3e..032177561e 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager_launch.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_launch.go @@ -285,6 +285,7 @@ func buildLaunchMetadata(req *LaunchRequest, mainRepoGitDir, worktreeID, worktre for k, v := range req.Metadata { metadata[k] = v } + putPrimaryCheckoutOptions(metadata, req) for k, v := range req.ExecutorConfig { if isTrustedExecutorConfigKey(k) { // Executor config wins for connection-routing keys so a malicious @@ -849,6 +850,9 @@ func invalidScratchPathID(id string) bool { // and populates metadata from the request fields. Runtime/profile environment // values are composed later, after every managed source has been collected. func (m *Manager) launchPrepareRequest(req *LaunchRequest, profileInfo *AgentProfileInfo, workspacePath string) (LaunchRequest, string, error) { + if err := m.validateLaunchCheckoutOptions(req); err != nil { + return LaunchRequest{}, "", err + } executionID := uuid.New().String() reqWithWorktree := *req reqWithWorktree.WorkspacePath = workspacePath @@ -1224,6 +1228,7 @@ func buildEnvPrepareRequest(req *LaunchRequest, workspacePath string, execName e CheckoutBranch: req.CheckoutBranch, PRNumber: req.PRNumber, RemoteContribution: req.RemoteContribution, + CheckoutOptions: req.CheckoutOptions, ContributionDestination: req.ContributionDestination, WorktreeBranch: getMetadataString(req.Metadata, MetadataKeyWorktreeBranch), WorktreeBranchPrefix: req.WorktreeBranchPrefix, @@ -1261,6 +1266,7 @@ func buildEnvPrepareRequest(req *LaunchRequest, workspacePath string, execName e CheckoutBranch: r.CheckoutBranch, PRNumber: r.PRNumber, RemoteContribution: r.RemoteContribution, + CheckoutOptions: r.CheckoutOptions, WorktreeID: r.WorktreeID, AllowBranchReplacement: req.AllowBranchReplacement || r.AllowBranchReplacement, WorktreeBranchPrefix: r.WorktreeBranchPrefix, diff --git a/apps/backend/internal/agent/runtime/lifecycle/repository_checkout_options.go b/apps/backend/internal/agent/runtime/lifecycle/repository_checkout_options.go new file mode 100644 index 0000000000..2bed7ca8ab --- /dev/null +++ b/apps/backend/internal/agent/runtime/lifecycle/repository_checkout_options.go @@ -0,0 +1,107 @@ +package lifecycle + +import ( + "fmt" + "strings" + + "github.com/kandev/kandev/internal/gitconfigenv" + "github.com/kandev/kandev/internal/githubauth" + "github.com/kandev/kandev/internal/task/models" +) + +const dockerCloneLine = "git clone --depth=1 --branch {{repository.branch}} {{repository.clone_url}} {{workspace.path}}" + +func checkoutOptionsPrepareScript(script string, options *models.RepositoryCheckoutOptions) (string, error) { + options, err := models.NormalizeRepositoryCheckoutOptions(options) + if err != nil { + return "", err + } + if options == nil || (options.DownloadMode == models.DownloadStandard && len(options.SparseDirectories) == 0) { + return script, nil + } + if script != DefaultPrepareScript("local_docker") { + return "", fmt.Errorf("checkout options require the built-in preparation script") + } + clone := "git clone --no-checkout --branch {{repository.branch}}" + if options.DownloadMode == models.DownloadOnDemand { + clone += " --filter=blob:none" + } + clone += " -- {{repository.clone_url}} {{workspace.path}}" + lines := []string{ + `checkout_log=$(mktemp)`, + `trap 'rm -f "$checkout_log"' EXIT`, + "if ! " + clone + ` 2>"$checkout_log"; then echo 'Repository clone failed. Check remote access and retry.' >&2; exit 1; fi`, + `if grep -qi 'filtering not recognized' "$checkout_log"; then echo 'Server does not support on-demand downloads' >&2; exit 1; fi`, + "cd {{workspace.path}}", + } + if len(options.SparseDirectories) > 0 { + quoted := make([]string, 0, len(options.SparseDirectories)) + for _, directory := range options.SparseDirectories { + quoted = append(quoted, shellQuote(`"`+strings.ReplaceAll(directory, `"`, `\"`)+`"`)) + } + lines = append(lines, `printf '%s\n' `+strings.Join(quoted, " ")+` | git sparse-checkout set --cone --stdin`) + } + lines = append(lines, "git read-tree -mu HEAD") + return strings.Replace(script, dockerCloneLine, strings.Join(lines, "\n"), 1), nil +} + +func primaryCheckoutOptions(metadata map[string]interface{}) (*models.RepositoryCheckoutOptions, error) { + return models.GetRepositoryCheckoutOptions(metadata) +} + +func (m *Manager) validateLaunchCheckoutOptions(req *LaunchRequest) error { + for _, spec := range req.RepoSpecs() { + options, err := models.NormalizeRepositoryCheckoutOptions(spec.CheckoutOptions) + if err != nil { + return err + } + if options == nil || (options.DownloadMode == models.DownloadStandard && len(options.SparseDirectories) == 0) { + continue + } + demand, sparse := m.SupportsRepositoryCheckoutOptions(req.ExecutorType, req.SetupScript) + if options.DownloadMode == models.DownloadOnDemand && !demand || len(options.SparseDirectories) > 0 && !sparse { + return fmt.Errorf("repository checkout options are unavailable for this preparation path") + } + } + return nil +} + +func putPrimaryCheckoutOptions(metadata map[string]interface{}, req *LaunchRequest) { + delete(metadata, models.RepositoryCheckoutOptionsKey) + specs := req.RepoSpecs() + if len(specs) > 0 && specs[0].CheckoutOptions != nil { + metadata[models.RepositoryCheckoutOptionsKey] = specs[0].CheckoutOptions + } +} + +func checkoutOptionsValidationScript(options *models.RepositoryCheckoutOptions) string { + if options == nil || len(options.SparseDirectories) == 0 { + return "" + } + lines := []string{"\ncd {{workspace.path}}"} + for _, directory := range options.SparseDirectories { + lines = append(lines, `case "$(git --literal-pathspecs ls-tree -d HEAD -- `+shellQuote(directory)+` | cut -d ' ' -f 1)" in 040000|160000) ;; *) echo 'Selected folder is unavailable at this revision' >&2; exit 1 ;; esac`) + } + return strings.Join(lines, "\n") + "\n" +} + +// checkoutCredentialEnvironment excludes profile controls from host Git commands. +func checkoutCredentialEnvironment(env map[string]string) map[string]string { + result := make(map[string]string) + for _, key := range append([]string{githubauth.CredentialHelperPathEnv, githubauth.CredentialCLIShimDirEnv}, managedGitCredentialBrokerEnvKeys...) { + if value, ok := env[key]; ok { + result[key] = value + } + } + filtered, err := gitconfigenv.Filter(env, func(index int, entries []gitconfigenv.Entry) bool { + entry := entries[index] + if isManagedSetupScriptGitHelper(index, entries) { + return true + } + return strings.HasPrefix(entry.Key, "credential.https://") && strings.HasSuffix(entry.Key, ".useHttpPath") && entry.Value == boolStringTrue + }) + if err == nil { + gitconfigenv.CopyIndexed(result, filtered) + } + return result +} diff --git a/apps/backend/internal/agent/runtime/lifecycle/repository_checkout_options_container_test.go b/apps/backend/internal/agent/runtime/lifecycle/repository_checkout_options_container_test.go new file mode 100644 index 0000000000..56f009d65c --- /dev/null +++ b/apps/backend/internal/agent/runtime/lifecycle/repository_checkout_options_container_test.go @@ -0,0 +1,94 @@ +package lifecycle + +import ( + "os" + "os/exec" + "strings" + "testing" + + "github.com/kandev/kandev/internal/task/models" +) + +// TestRepositoryCheckoutOptionsContainer exercises the shipped shell/Git sink +// inside the same image used by executor E2E tests. No host checkout is mounted. +func TestRepositoryCheckoutOptionsContainer(t *testing.T) { + if os.Getenv("KANDEV_E2E_CONTAINERS") != "1" { + t.Skip("requires explicit container test run") + } + options := &models.RepositoryCheckoutOptions{Version: 1, DownloadMode: models.DownloadOnDemand, SparseDirectories: []string{"app"}} + prepare, err := checkoutOptionsPrepareScript(DefaultPrepareScript("local_docker"), options) + if err != nil { + t.Fatal(err) + } + prepare = strings.NewReplacer("{{repository.clone_url}}", "http://127.0.0.1:18473/origin.git", "{{workspace.path}}", "/tmp/checkout", "{{repository.branch}}", "main", "{{repository.setup_script}}", ":", "{{git.identity_setup}}", ":", "{{github.auth_setup}}", ":").Replace(prepare) + setup := `set -eu +export GIT_CONFIG_GLOBAL=/tmp/test-gitconfig +mkdir -p /tmp/source/app /tmp/source/other +cd /tmp/source +git init -b main +git config user.name Fixture +git config user.email fixture@example.test +git config uploadpack.allowFilter true +printf app > app/file.txt +printf other > other/file.txt +printf historical > old.txt +printf revoked > revoked.txt +git add . +git -c commit.gpgsign=false commit -m history +historical_blob=$(git rev-parse HEAD:old.txt) +revoked_blob=$(git rev-parse HEAD:revoked.txt) +git rm old.txt revoked.txt +git -c commit.gpgsign=false commit -m remove +git clone --bare /tmp/source /tmp/origin.git +git -C /tmp/origin.git config uploadpack.allowFilter true +git -C /tmp/origin.git config uploadpack.allowAnySHA1InWant true +cat > /tmp/git-server.cjs <<'JS' +const http = require('node:http'); +const fs = require('node:fs'); +const { spawn } = require('node:child_process'); +const auth = 'Basic ' + Buffer.from('fixture:fixture-secret').toString('base64'); +http.createServer((req,res) => { + if (fs.existsSync('/tmp/revoked') || req.headers.authorization !== auth) { res.writeHead(401, {'WWW-Authenticate':'Basic realm="fixture"'}); res.end(); return; } + const url = new URL(req.url,'http://localhost'); + const child=spawn('git',['http-backend'],{env:{...process.env,GIT_PROJECT_ROOT:'/tmp',GIT_HTTP_EXPORT_ALL:'1',PATH_INFO:url.pathname,QUERY_STRING:url.search.slice(1),REQUEST_METHOD:req.method,CONTENT_TYPE:req.headers['content-type'] || '',SERVER_PROTOCOL:'HTTP/1.1'}}); + req.pipe(child.stdin);const chunks=[];child.stdout.on('data',chunk=>chunks.push(chunk)); + child.on('close',()=>{const out=Buffer.concat(chunks);const split=out.indexOf('\r\n\r\n');if(split<0){res.writeHead(500);res.end();return;}for(const line of out.subarray(0,split).toString().split('\r\n')){const i=line.indexOf(':');if(i>0){const key=line.slice(0,i);const value=line.slice(i+1).trim();if(key.toLowerCase()==='status')res.statusCode=parseInt(value);else res.setHeader(key,value);}}res.end(out.subarray(split+4));}); +}).listen(18473,'127.0.0.1',()=>fs.writeFileSync('/tmp/git-ready','ready')); +JS +node /tmp/git-server.cjs >/tmp/git-server.log 2>&1 & +server_pid=$! +for i in $(seq 1 50); do [ -f /tmp/git-ready ] && break; sleep 0.1; done +test -f /tmp/git-ready +cat > /tmp/initial-helper <<'SH' +#!/bin/sh +printf 'username=fixture\npassword=fixture-secret\n' +SH +chmod +x /tmp/initial-helper +export GIT_TERMINAL_PROMPT=0 GIT_CONFIG_COUNT=3 +export GIT_CONFIG_KEY_0=credential.helper GIT_CONFIG_VALUE_0= +export GIT_CONFIG_KEY_1=credential.http://127.0.0.1:18473.helper GIT_CONFIG_VALUE_1='!/tmp/initial-helper' +export GIT_CONFIG_KEY_2=credential.useHttpPath GIT_CONFIG_VALUE_2=true +` + verify := ` +test -f /tmp/checkout/app/file.txt +test ! -e /tmp/checkout/other/file.txt +test "$(git -C /tmp/checkout rev-parse --is-shallow-repository)" = false +! git -C /tmp/checkout cat-file --batch-all-objects --batch-check='%(objectname)' | grep -Fx "$historical_blob" +cp /tmp/initial-helper /tmp/execution-helper +rm /tmp/initial-helper +export GIT_CONFIG_VALUE_1='!/tmp/execution-helper' +test "$(git -C /tmp/checkout cat-file blob "$historical_blob")" = historical +! git -C /tmp/checkout config --local --list | grep -F fixture-secret +touch /tmp/revoked +if git -C /tmp/checkout cat-file blob "$revoked_blob" >/tmp/revoked-output 2>&1; then exit 1; fi +! grep -F fixture-secret /tmp/revoked-output +kill "$server_pid" +printf 'checkout-container-passed\n' +` + cmd := exec.Command("docker", "run", "--rm", "-i", "--entrypoint", "bash", "kandev-agent:e2e", "-e", "-s") + cmd.Stdin = strings.NewReader(setup + "\n" + prepare + "\n" + verify) + out, err := cmd.CombinedOutput() + if err != nil || !strings.Contains(string(out), "checkout-container-passed") { + t.Fatalf("container checkout: %v\n%s", err, out) + } +} diff --git a/apps/backend/internal/agent/runtime/lifecycle/repository_checkout_options_test.go b/apps/backend/internal/agent/runtime/lifecycle/repository_checkout_options_test.go new file mode 100644 index 0000000000..075509fc63 --- /dev/null +++ b/apps/backend/internal/agent/runtime/lifecycle/repository_checkout_options_test.go @@ -0,0 +1,167 @@ +package lifecycle + +import ( + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/kandev/kandev/internal/task/models" +) + +func TestRepositoryCheckoutOptionsReachWorktree(t *testing.T) { + var req EnvPrepareRequest + if err := json.Unmarshal([]byte(`{"CheckoutOptions":{"version":1,"download_mode":"on_demand","sparse_directories":["app"]}}`), &req); err != nil { + t.Fatal(err) + } + got := buildWorktreeCreateRequest(&req).CheckoutOptions + if got == nil || got.DownloadMode != "on_demand" || len(got.SparseDirectories) != 1 { + t.Fatalf("options lost: %#v", got) + } +} + +func TestRepositoryCheckoutCapabilities(t *testing.T) { + manager := &Manager{} + for _, tt := range []struct { + executor, script string + want bool + }{ + {"worktree", "", true}, {"worktree", DefaultPrepareScript("worktree"), true}, + {"local", "", false}, {"worktree", "echo custom", false}, {"ssh", "", false}, + } { + demand, sparse := manager.SupportsRepositoryCheckoutOptions(tt.executor, tt.script) + if demand != tt.want || sparse != tt.want { + t.Fatalf("%s capability = %v,%v", tt.executor, demand, sparse) + } + } +} + +func TestRepositoryCheckoutOptionsDockerPrimary(t *testing.T) { + isolateGitEnv(t) + root := t.TempDir() + t.Setenv("GIT_CONFIG_GLOBAL", filepath.Join(root, "gitconfig")) + source := filepath.Join(root, "source") + destination := filepath.Join(root, "checkout") + if err := os.Mkdir(source, 0755); err != nil { + t.Fatal(err) + } + runIn(t, source, "git", "init", "-b", "main") + for _, dir := range []string{"app", "other"} { + if err := os.Mkdir(filepath.Join(source, dir), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(source, dir, "file.txt"), []byte(dir), 0600); err != nil { + t.Fatal(err) + } + } + runIn(t, source, "git", "add", ".") + runIn(t, source, "git", "-c", "user.name=Test", "-c", "user.email=test@example.com", "-c", "commit.gpgsign=false", "commit", "-m", "folders") + options := &models.RepositoryCheckoutOptions{Version: 1, DownloadMode: "on_demand", SparseDirectories: []string{"app"}} + runIn(t, source, "git", "config", "uploadpack.allowFilter", "true") + script, err := checkoutOptionsPrepareScript(DefaultPrepareScript("local_docker"), options) + if err != nil { + t.Fatal(err) + } + script = strings.NewReplacer("{{repository.clone_url}}", shellQuote("file://"+source), "{{workspace.path}}", shellQuote(destination), "{{repository.branch}}", "main", "{{repository.setup_script}}", ":", "{{git.identity_setup}}", ":", "{{github.auth_setup}}", ":").Replace(script) + cmd := exec.Command("bash", "-e", "-c", script) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("%s: %v", out, err) + } + if _, err := os.Stat(filepath.Join(destination, "app", "file.txt")); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(destination, "other")); !os.IsNotExist(err) { + t.Fatal("excluded folder populated") + } + if got := strings.TrimSpace(string(runIn(t, destination, "git", "rev-parse", "--is-shallow-repository"))); got != "false" { + t.Fatal("shallow checkout") + } +} + +func TestRepositoryCheckoutOptionsDockerValidatesBeforeSetup(t *testing.T) { + req := &ExecutorCreateRequest{Metadata: map[string]interface{}{ + models.RepositoryCheckoutOptionsKey: &models.RepositoryCheckoutOptions{Version: 1, DownloadMode: "standard", SparseDirectories: []string{"missing"}}, + "repository_setup_script": "echo SETUP_SIDE_EFFECT", + }} + script, err := (&DockerExecutor{}).resolvePrepareScript(req) + if err != nil { + t.Fatal(err) + } + validation := strings.Index(script, "Selected folder is unavailable") + setup := strings.Index(script, "SETUP_SIDE_EFFECT") + if setup < 0 { + t.Fatalf("setup fixture not resolved: %s", script) + } + if validation < 0 || validation > setup { + t.Fatal("repository setup executes before folder validation") + } +} + +func TestRepositoryCheckoutOptionsDockerMissingFolderSkipsSetup(t *testing.T) { + isolateGitEnv(t) + root := t.TempDir() + t.Setenv("GIT_CONFIG_GLOBAL", filepath.Join(root, "gitconfig")) + source := filepath.Join(root, "source") + if err := os.Mkdir(source, 0755); err != nil { + t.Fatal(err) + } + runIn(t, source, "git", "init", "-b", "main") + runIn(t, source, "git", "-c", "user.name=Test", "-c", "user.email=test@example.com", "-c", "commit.gpgsign=false", "commit", "--allow-empty", "-m", "initial") + marker := filepath.Join(root, "setup-ran") + req := &ExecutorCreateRequest{Metadata: map[string]interface{}{ + models.RepositoryCheckoutOptionsKey: &models.RepositoryCheckoutOptions{Version: 1, DownloadMode: "standard", SparseDirectories: []string{"missing"}}, + "repository_setup_script": "touch " + shellQuote(marker), + "repository_clone_url": "file://" + source, + "base_branch": "main", + }} + script, err := (&DockerExecutor{}).resolvePrepareScript(req) + if err != nil { + t.Fatal(err) + } + script = strings.ReplaceAll(script, "/workspace", filepath.Join(root, "checkout")) + output, err := exec.Command("bash", "-e", "-c", script).CombinedOutput() + if err == nil || !strings.Contains(string(output), "Selected folder is unavailable") { + t.Fatalf("missing folder was not rejected: %s %v", output, err) + } + if _, err := os.Stat(marker); !os.IsNotExist(err) { + t.Fatal("setup ran before folder rejection") + } +} + +func TestRepositoryCheckoutOptionsCloneDiagnosticsDoNotLeak(t *testing.T) { + isolateGitEnv(t) + root := t.TempDir() + git := filepath.Join(root, "git") + if err := os.WriteFile(git, []byte("#!/bin/sh\nif [ \"$1\" = clone ]; then echo 'https://user:fixture-secret@example.test/repo' >&2; exit 1; fi\n"), 0755); err != nil { + t.Fatal(err) + } + script, err := checkoutOptionsPrepareScript(DefaultPrepareScript("local_docker"), &models.RepositoryCheckoutOptions{Version: 1, DownloadMode: "on_demand"}) + if err != nil { + t.Fatal(err) + } + script = strings.NewReplacer("{{repository.clone_url}}", "https://example.test/repo", "{{workspace.path}}", shellQuote(root), "{{repository.branch}}", "main", "{{repository.setup_script}}", ":", "{{git.identity_setup}}", ":", "{{github.auth_setup}}", ":").Replace(script) + cmd := exec.Command("bash", "-e", "-c", script) + cmd.Env = append(os.Environ(), "PATH="+root+":"+os.Getenv("PATH")) + output, err := cmd.CombinedOutput() + if err == nil { + t.Fatal("failed clone accepted") + } + if strings.Contains(string(output), "fixture-secret") { + t.Fatal("clone diagnostics leaked credentials") + } +} + +func TestRepositoryCheckoutOptionsDockerAcceptsUnfetchedSubmodule(t *testing.T) { + isolateGitEnv(t) + root := t.TempDir() + runIn(t, root, "git", "init", "-b", "main") + runIn(t, root, "git", "update-index", "--add", "--cacheinfo", "160000,1111111111111111111111111111111111111111,module") + runIn(t, root, "git", "-c", "user.name=Test", "-c", "user.email=test@example.com", "-c", "commit.gpgsign=false", "commit", "-m", "submodule") + script := checkoutOptionsValidationScript(&models.RepositoryCheckoutOptions{Version: 1, DownloadMode: "standard", SparseDirectories: []string{"module"}}) + script = strings.ReplaceAll(script, "{{workspace.path}}", shellQuote(root)) + if output, err := exec.Command("bash", "-e", "-c", script).CombinedOutput(); err != nil { + t.Fatalf("submodule rejected: %s %v", output, err) + } +} diff --git a/apps/backend/internal/agent/runtime/lifecycle/types.go b/apps/backend/internal/agent/runtime/lifecycle/types.go index 8a4d58df0e..8cce5097a7 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/types.go +++ b/apps/backend/internal/agent/runtime/lifecycle/types.go @@ -1115,6 +1115,7 @@ type RepoLaunchSpec struct { CheckoutBranch string PRNumber int // GitHub PR number when CheckoutBranch is a PR head; enables refs/pull//head fetch for fork PRs. RemoteContribution *models.RemoteContribution + CheckoutOptions *models.RepositoryCheckoutOptions WorktreeID string // Existing worktree ID to reuse (skip creation if set) // AllowBranchReplacement permits the explicit new-branch recovery action for // this repository while retaining its environment record. @@ -1273,6 +1274,7 @@ type LaunchRequest struct { CheckoutBranch string // Branch to fetch and checkout after worktree creation (e.g., PR head branch) PRNumber int // GitHub PR number when CheckoutBranch is a PR head; enables refs/pull//head fetch for fork PRs. RemoteContribution *models.RemoteContribution + CheckoutOptions *models.RepositoryCheckoutOptions ComparisonTarget *models.ComparisonTarget WorktreeBranchPrefix string // Branch prefix for worktree branches WorktreeBranchTemplate string // Branch name template for worktree branches @@ -1328,6 +1330,7 @@ func (r *LaunchRequest) RepoSpecs() []RepoLaunchSpec { CheckoutBranch: r.CheckoutBranch, PRNumber: r.PRNumber, RemoteContribution: r.RemoteContribution, + CheckoutOptions: r.CheckoutOptions, ComparisonTarget: r.ComparisonTarget, ContributionDestination: r.ContributionDestination, WorktreeID: r.WorktreeID, diff --git a/apps/backend/internal/agent/runtime/lifecycle/workspace_materialization.go b/apps/backend/internal/agent/runtime/lifecycle/workspace_materialization.go index a4dd1c45b6..ee14f5bc82 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/workspace_materialization.go +++ b/apps/backend/internal/agent/runtime/lifecycle/workspace_materialization.go @@ -16,6 +16,7 @@ import ( // prepared for a running remote workspace. It deliberately carries only a // credential-free locator; executor launch environments provide Git auth. type WorkspaceRepositoryMaterialization struct { + CheckoutOptions *models.RepositoryCheckoutOptions RepositoryURL string Destination string BaseBranch string @@ -50,7 +51,7 @@ func remoteWorkspaceProjectionFromLaunch(req *LaunchRequest) ([]WorkspaceReposit if name == "" || branchSlug == "" { return nil, fmt.Errorf("remote repository %q has unsafe runtime name", spec.RepoName) } - projection = append(projection, WorkspaceRepositoryMaterialization{RepositoryURL: spec.RepositoryURL, Destination: name + "-" + branchSlug, BaseBranch: spec.BaseBranch, CheckoutBranch: spec.CheckoutBranch, RemoteContribution: spec.RemoteContribution, ContributionDestination: spec.ContributionDestination}) + projection = append(projection, WorkspaceRepositoryMaterialization{RepositoryURL: spec.RepositoryURL, Destination: name + "-" + branchSlug, BaseBranch: spec.BaseBranch, CheckoutBranch: spec.CheckoutBranch, RemoteContribution: spec.RemoteContribution, CheckoutOptions: spec.CheckoutOptions, ContributionDestination: spec.ContributionDestination}) } return projection, nil } @@ -209,6 +210,7 @@ func materializeWorkspaceRepositoriesWithoutRescan(ctx context.Context, client w BaseBranch: repository.BaseBranch, CheckoutBranch: repository.CheckoutBranch, RemoteContribution: repository.RemoteContribution, + CheckoutOptions: repository.CheckoutOptions, ContributionDestination: repository.ContributionDestination, }) if err != nil { diff --git a/apps/backend/internal/agentctl/server/api/workspace_checkout_options_test.go b/apps/backend/internal/agentctl/server/api/workspace_checkout_options_test.go new file mode 100644 index 0000000000..977d8bdbe4 --- /dev/null +++ b/apps/backend/internal/agentctl/server/api/workspace_checkout_options_test.go @@ -0,0 +1,45 @@ +package api + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func TestMaterializeRepositoryOptionsSparse(t *testing.T) { + origin := createMaterializeOrigin(t) + seed := filepath.Join(t.TempDir(), "seed") + runMaterializeTestGit(t, filepath.Dir(seed), "clone", origin, seed) + for _, dir := range []string{"app", "other"} { + if err := os.Mkdir(filepath.Join(seed, dir), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(seed, dir, "file.txt"), []byte(dir), 0600); err != nil { + t.Fatal(err) + } + } + runMaterializeTestGit(t, seed, "add", ".") + runMaterializeTestGit(t, seed, "-c", "user.name=Test", "-c", "user.email=test@example.com", "-c", "commit.gpgsign=false", "commit", "-m", "folders") + runMaterializeTestGit(t, seed, "push", "origin", "main") + var req MaterializeRepositoryRequest + if err := json.Unmarshal([]byte(`{"checkout_options":{"version":1,"download_mode":"standard","sparse_directories":["app"]}}`), &req); err != nil { + t.Fatal(err) + } + destination := filepath.Join(t.TempDir(), "checkout") + _, err := materializeRepositoryWithOptions(context.Background(), origin, destination, "main", "", nil, nil, req.CheckoutOptions) + if err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(destination, "app", "file.txt")); err != nil { + t.Fatal(err) + } + if _, err := materializeRepository(context.Background(), origin, destination, "main", ""); err == nil { + t.Fatal("default request silently reused a sparse checkout") + } + + if _, err := os.Stat(filepath.Join(destination, "other")); !os.IsNotExist(err) { + t.Fatal("excluded directory downloaded") + } +} diff --git a/apps/backend/internal/agentctl/server/api/workspace_materialize.go b/apps/backend/internal/agentctl/server/api/workspace_materialize.go index 12335e7a79..08db08cfb0 100644 --- a/apps/backend/internal/agentctl/server/api/workspace_materialize.go +++ b/apps/backend/internal/agentctl/server/api/workspace_materialize.go @@ -17,6 +17,7 @@ import ( "github.com/gin-gonic/gin" "github.com/kandev/kandev/internal/common/securityutil" "github.com/kandev/kandev/internal/common/subproc" + "github.com/kandev/kandev/internal/gitcheckout" "github.com/kandev/kandev/internal/task/models" "go.uber.org/zap" ) @@ -25,12 +26,13 @@ import ( // agentctl workspace. RepositoryURL must be a credential-free Git locator; // destination is always a direct child of the current workspace root. type MaterializeRepositoryRequest struct { - RepositoryURL string `json:"repository_url"` - Destination string `json:"destination"` - BaseBranch string `json:"base_branch"` - CheckoutBranch string `json:"checkout_branch,omitempty"` - RemoteContribution *models.RemoteContribution `json:"remote_contribution,omitempty"` - ContributionDestination *models.ContributionDestination `json:"contribution_destination,omitempty"` + CheckoutOptions *models.RepositoryCheckoutOptions `json:"checkout_options,omitempty"` + RepositoryURL string `json:"repository_url"` + Destination string `json:"destination"` + BaseBranch string `json:"base_branch"` + CheckoutBranch string `json:"checkout_branch,omitempty"` + RemoteContribution *models.RemoteContribution `json:"remote_contribution,omitempty"` + ContributionDestination *models.ContributionDestination `json:"contribution_destination,omitempty"` } // MaterializeRepositoryResponse deliberately contains no remote locator so a @@ -89,8 +91,13 @@ func (s *Server) handleWorkspaceMaterializeRepository(c *gin.Context) { } } - reused, err := materializeRepositoryWithDestination(c.Request.Context(), req.RepositoryURL, destination, req.BaseBranch, req.CheckoutBranch, req.RemoteContribution, req.ContributionDestination) + reused, err := materializeRepositoryWithOptions(c.Request.Context(), req.RepositoryURL, destination, req.BaseBranch, req.CheckoutBranch, req.RemoteContribution, req.ContributionDestination, req.CheckoutOptions) if err != nil { + var directoryErr *gitcheckout.DirectoryError + if errors.As(err, &directoryErr) { + c.JSON(http.StatusUnprocessableEntity, MaterializeRepositoryResponse{Error: directoryErr.Error()}) + return + } if errors.Is(err, errMaterializeCollision) { c.JSON(http.StatusConflict, MaterializeRepositoryResponse{Error: "destination already exists"}) return @@ -230,7 +237,19 @@ func materializeRepositoryWithDestination(ctx context.Context, locator, destinat } func materializeRepositoryInternal(ctx context.Context, locator, destination, baseBranch, checkoutBranch string, binding *models.RemoteContribution, contributionDestination *models.ContributionDestination) (bool, error) { + return materializeRepositoryWithOptions(ctx, locator, destination, baseBranch, checkoutBranch, binding, contributionDestination, nil) +} + +func materializeRepositoryWithOptions(ctx context.Context, locator, destination, baseBranch, checkoutBranch string, binding *models.RemoteContribution, contributionDestination *models.ContributionDestination, options *models.RepositoryCheckoutOptions) (bool, error) { + options, err := models.NormalizeRepositoryCheckoutOptions(options) + if err != nil { + return false, err + } + if reused, err := matchingCheckoutWithDestination(ctx, destination, locator, baseBranch, checkoutBranch, binding, contributionDestination); err != nil || reused { + if err == nil && reused { + err = gitcheckout.Check(destination, options) + } return reused, err } // codeql[go/path-injection] destination is a direct child of the canonical workspace root; Lstat rejects links before use. @@ -247,20 +266,14 @@ func materializeRepositoryInternal(ctx context.Context, locator, destination, ba } defer func() { _ = os.RemoveAll(tmp) }() checkout := filepath.Join(tmp, "checkout") - if _, err := materializeGitOutput(ctx, "clone", "--no-checkout", "--", locator, checkout); err != nil { + if err := cloneMaterializedRepository(ctx, locator, checkout, options); err != nil { return false, err } - if binding != nil { - if err := materializeRemoteContribution(ctx, checkout, binding); err != nil { - return false, err - } - } else if err := checkoutMaterializedBranch(ctx, checkout, baseBranch, checkoutBranch); err != nil { + if err := populateMaterializedRepository(ctx, checkout, baseBranch, checkoutBranch, binding, options); err != nil { return false, err } - if contributionDestination != nil { - if err := configureContributionDestination(ctx, checkout, contributionDestination); err != nil { - return false, err - } + if err := configureContributionDestination(ctx, checkout, contributionDestination); err != nil { + return false, err } // codeql[go/path-injection] checkout is newly created beneath the trusted workspace root; destination is its direct child. if err := os.Rename(checkout, destination); err != nil { @@ -272,6 +285,39 @@ func materializeRepositoryInternal(ctx context.Context, locator, destination, ba return false, nil } +func cloneMaterializedRepository(ctx context.Context, locator, checkout string, options *models.RepositoryCheckoutOptions) error { + args := []string{"clone", "--no-checkout"} + if options != nil && options.DownloadMode == models.DownloadOnDemand { + args = append(args, "--filter=blob:none") + } + args = append(args, "--", locator, checkout) + out, err := materializeGitOutput(ctx, args...) + if err != nil { + return err + } + if options != nil && options.DownloadMode == models.DownloadOnDemand && strings.Contains(strings.ToLower(out), "filtering not recognized") { + return errors.New("server does not support on-demand downloads") + } + return gitcheckout.ConfigureSparse(ctx, checkout, options, materializeCheckoutGit) +} + +func populateMaterializedRepository(ctx context.Context, checkout, baseBranch, checkoutBranch string, binding *models.RemoteContribution, options *models.RepositoryCheckoutOptions) error { + if binding != nil { + if err := materializeRemoteContribution(ctx, checkout, binding); err != nil { + return err + } + } else if err := checkoutMaterializedBranch(ctx, checkout, baseBranch, checkoutBranch); err != nil { + return err + } + if err := gitcheckout.ApplySparse(ctx, checkout, options, materializeCheckoutGit); err != nil { + return err + } + if err := gitcheckout.Save(checkout, options); err != nil { + return err + } + return nil +} + func matchingCheckoutWithDestination(ctx context.Context, destination, locator, baseBranch, checkoutBranch string, binding *models.RemoteContribution, contributionDestination *models.ContributionDestination) (bool, error) { reused, err := matchingCheckout(ctx, destination, locator, baseBranch, checkoutBranch, binding) if err != nil || !reused || contributionDestination == nil { @@ -765,3 +811,15 @@ func materializeGitTimeout(args []string) time.Duration { } return 30 * time.Second } + +func materializeCheckoutGit(ctx context.Context, args []string, input string) ([]byte, error) { + output, runErr, ctxErr := subproc.RunGitCombinedAfterAcquire(ctx, subproc.GitLifecycle, materializeGitTimeout(args), func(execCtx context.Context) *exec.Cmd { + cmd := subproc.NewGitCommand(execCtx, args...) + cmd.Stdin = strings.NewReader(input) + return cmd + }) + if runErr != nil || ctxErr != nil { + return nil, errors.Join(errors.New("git checkout command failed"), ctxErr) + } + return output, nil +} diff --git a/apps/backend/internal/backendapp/adapters.go b/apps/backend/internal/backendapp/adapters.go index a9d24f7481..6759d7e1e3 100644 --- a/apps/backend/internal/backendapp/adapters.go +++ b/apps/backend/internal/backendapp/adapters.go @@ -479,6 +479,7 @@ func buildLifecycleLaunchRequest( CheckoutBranch: req.CheckoutBranch, PRNumber: req.PRNumber, RemoteContribution: req.RemoteContribution, + CheckoutOptions: req.CheckoutOptions, ContributionDestination: req.ContributionDestination, ComparisonTarget: req.ComparisonTarget, WorktreeBranchPrefix: req.WorktreeBranchPrefix, @@ -544,6 +545,7 @@ func lifecycleRepoLaunchSpecs(repos []executor.RepoSpec) []lifecycle.RepoLaunchS CheckoutBranch: r.CheckoutBranch, PRNumber: r.PRNumber, RemoteContribution: r.RemoteContribution, + CheckoutOptions: r.CheckoutOptions, ContributionDestination: r.ContributionDestination, ComparisonTarget: r.ComparisonTarget, WorktreeID: r.WorktreeID, diff --git a/apps/backend/internal/backendapp/main.go b/apps/backend/internal/backendapp/main.go index 85d20abde9..495da44aaf 100644 --- a/apps/backend/internal/backendapp/main.go +++ b/apps/backend/internal/backendapp/main.go @@ -657,6 +657,13 @@ func startAgentInfrastructure( services.Task.SetAgentBaseBranchPusher(lifecycleMgr) services.Task.SetAgentComparisonTargetPusher(lifecycleMgr) services.Task.SetExecutorCapabilityProber(lifecycleMgr) + services.Task.SetRepositoryCheckoutCredentialPolicy(func(ctx context.Context, workspaceID string) (bool, error) { + if services.GitHub == nil { + return false, nil + } + policy, err := services.GitHub.DescribeTaskGitCredentialPolicy(ctx, workspaceID) + return policy.Mode == githubpkg.TaskGitCredentialsModeManaged, err + }) // Session/environment-scoped HTTP surfaces (shell, files, ports, vscode, // LSP, terminals) enforce per-user workspace scoping (opt-in auth). The diff --git a/apps/backend/internal/gitcheckout/identity.go b/apps/backend/internal/gitcheckout/identity.go new file mode 100644 index 0000000000..54133b7f42 --- /dev/null +++ b/apps/backend/internal/gitcheckout/identity.go @@ -0,0 +1,55 @@ +package gitcheckout + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "path/filepath" + + "github.com/kandev/kandev/internal/common/gitref" + "github.com/kandev/kandev/internal/task/models" +) + +const identityFile = "kandev-checkout-options.json" + +func Save(path string, options *models.RepositoryCheckoutOptions) error { + if options == nil { + return nil + } + dir, err := gitref.ResolveGitDir(path) + if err != nil { + return err + } + data, err := json.Marshal(options) + if err != nil { + return err + } + return os.WriteFile(filepath.Join(dir, identityFile), data, 0600) +} + +// Check preserves user work: an existing checkout may only be reused with its original scope. +func Check(path string, options *models.RepositoryCheckoutOptions) error { + if options == nil { + options = &models.RepositoryCheckoutOptions{Version: 1, DownloadMode: models.DownloadStandard, SparseDirectories: []string{}} + } + dir, err := gitref.ResolveGitDir(path) + if err != nil { + return err + } + data, err := os.ReadFile(filepath.Join(dir, identityFile)) + if os.IsNotExist(err) && options.DownloadMode == models.DownloadStandard && len(options.SparseDirectories) == 0 { + return nil + } + if err != nil { + return fmt.Errorf("checkout options identity is unavailable: %w", err) + } + expected, err := json.Marshal(options) + if err != nil { + return err + } + if !bytes.Equal(data, expected) { + return fmt.Errorf("checkout options differ from the existing workspace; create a new task") + } + return nil +} diff --git a/apps/backend/internal/gitcheckout/sparse.go b/apps/backend/internal/gitcheckout/sparse.go new file mode 100644 index 0000000000..0baac452e8 --- /dev/null +++ b/apps/backend/internal/gitcheckout/sparse.go @@ -0,0 +1,67 @@ +// Package gitcheckout applies task-owned checkout scopes before file population. +package gitcheckout + +import ( + "context" + "fmt" + "strings" + + "github.com/kandev/kandev/internal/task/models" +) + +// Run executes Git with the caller's credentials, admission, and cancellation. +type Run func(context.Context, []string, string) ([]byte, error) + +func ApplySparse(ctx context.Context, path string, options *models.RepositoryCheckoutOptions, run Run) error { + options, err := models.NormalizeRepositoryCheckoutOptions(options) + if err != nil || options == nil || len(options.SparseDirectories) == 0 { + return err + } + for _, directory := range options.SparseDirectories { + out, err := run(ctx, []string{"-C", path, "--literal-pathspecs", "ls-tree", "-d", "-z", "HEAD", "--", directory}, "") + if err != nil { + return fmt.Errorf("inspect selected checkout directory: %w", err) + } + directoryLike := strings.HasPrefix(string(out), "040000 tree ") || strings.HasPrefix(string(out), "160000 commit ") + if !directoryLike || !strings.HasSuffix(string(out), "\t"+directory+"\x00") { + return &DirectoryError{Directory: directory} + } + } + if err := ConfigureSparse(ctx, path, options, run); err != nil { + return err + } + // A --no-checkout worktree has no populated index for sparse-checkout set + // to update. Read HEAD only after the sparse patterns are installed. + if _, err := run(ctx, []string{"-C", path, "read-tree", "-mu", "HEAD"}, ""); err != nil { + return fmt.Errorf("populate selected checkout folders: %w", err) + } + return nil +} + +// ConfigureSparse installs patterns on an empty index before a branch checkout. +func ConfigureSparse(ctx context.Context, path string, options *models.RepositoryCheckoutOptions, run Run) error { + if options == nil || len(options.SparseDirectories) == 0 { + return nil + } + _, err := run(ctx, []string{"-C", path, "sparse-checkout", "set", "--cone", "--stdin"}, SparseInput(options.SparseDirectories)) + if err != nil { + return fmt.Errorf("configure selected checkout folders: %w", err) + } + return nil +} + +// SparseInput uses Git's quoted pathname format, preserving quotes and Unicode. +func SparseInput(directories []string) string { + quoted := make([]string, 0, len(directories)) + for _, directory := range directories { + quoted = append(quoted, `"`+strings.ReplaceAll(directory, `"`, `\"`)+`"`) + } + return strings.Join(quoted, "\n") + "\n" +} + +// DirectoryError contains only a validated repository-relative directory. +type DirectoryError struct{ Directory string } + +func (e *DirectoryError) Error() string { + return fmt.Sprintf("selected folder %q is not a directory at the selected revision", e.Directory) +} diff --git a/apps/backend/internal/orchestrator/executor/executor.go b/apps/backend/internal/orchestrator/executor/executor.go index 5b41f99085..a3481514e9 100644 --- a/apps/backend/internal/orchestrator/executor/executor.go +++ b/apps/backend/internal/orchestrator/executor/executor.go @@ -550,6 +550,7 @@ type LaunchAgentRequest struct { CheckoutBranch string // Branch to fetch and checkout after worktree creation (e.g., PR head branch) PRNumber int // GitHub PR number when CheckoutBranch is a PR head; enables refs/pull//head fetch for fork PRs. RemoteContribution *models.RemoteContribution + CheckoutOptions *models.RepositoryCheckoutOptions ContributionDestination *models.ContributionDestination ComparisonTarget *models.ComparisonTarget WorktreeBranchPrefix string // Branch prefix for worktree branches @@ -605,6 +606,7 @@ type RepoSpec struct { CheckoutBranch string PRNumber int // GitHub PR number when CheckoutBranch is a PR head; enables refs/pull//head fetch for fork PRs. RemoteContribution *models.RemoteContribution + CheckoutOptions *models.RepositoryCheckoutOptions ContributionDestination *models.ContributionDestination ComparisonTarget *models.ComparisonTarget WorktreeID string diff --git a/apps/backend/internal/orchestrator/executor/executor_environment_reuse.go b/apps/backend/internal/orchestrator/executor/executor_environment_reuse.go index b0618b938e..2ac4c0827b 100644 --- a/apps/backend/internal/orchestrator/executor/executor_environment_reuse.go +++ b/apps/backend/internal/orchestrator/executor/executor_environment_reuse.go @@ -390,6 +390,7 @@ func topLevelLaunchRepoSpec(req *LaunchAgentRequest) (RepoSpec, bool) { } return RepoSpec{ TaskRepositoryID: req.TaskRepositoryID, + CheckoutOptions: req.CheckoutOptions, RepositoryID: req.RepositoryID, RepositoryPath: req.RepositoryPath, RepositoryURL: req.RepositoryURL, diff --git a/apps/backend/internal/orchestrator/executor/executor_execute.go b/apps/backend/internal/orchestrator/executor/executor_execute.go index cd1b879349..c134e2ce3f 100644 --- a/apps/backend/internal/orchestrator/executor/executor_execute.go +++ b/apps/backend/internal/orchestrator/executor/executor_execute.go @@ -2197,6 +2197,7 @@ func buildRepoSpecs(allRepos []*repoInfo) []RepoSpec { CheckoutBranch: info.CheckoutBranch, PRNumber: info.PRNumber, RemoteContribution: info.RemoteContribution, + CheckoutOptions: info.CheckoutOptions, ContributionDestination: info.ContributionDestination, ComparisonTarget: info.ComparisonTarget, WorktreeBranchPrefix: info.WorktreeBranchPrefix, @@ -2271,6 +2272,7 @@ func (e *Executor) applyRepositoryConfig(req *LaunchAgentRequest, task *v1.Task, req.CheckoutBranch = repoInfo.CheckoutBranch req.PRNumber = repoInfo.PRNumber req.RemoteContribution = repoInfo.RemoteContribution + req.CheckoutOptions = repoInfo.CheckoutOptions req.ContributionDestination = repoInfo.ContributionDestination req.ComparisonTarget = repoInfo.ComparisonTarget req.WorktreeBranchPrefix = repoInfo.WorktreeBranchPrefix diff --git a/apps/backend/internal/orchestrator/executor/executor_resume.go b/apps/backend/internal/orchestrator/executor/executor_resume.go index 8096d72acf..d3457b1a11 100644 --- a/apps/backend/internal/orchestrator/executor/executor_resume.go +++ b/apps/backend/internal/orchestrator/executor/executor_resume.go @@ -72,6 +72,7 @@ type repoInfo struct { CheckoutBranch string PRNumber int // GitHub PR number when CheckoutBranch is a PR head; sourced from task_repositories.metadata["pr_number"]. RemoteContribution *models.RemoteContribution + CheckoutOptions *models.RepositoryCheckoutOptions ContributionDestination *models.ContributionDestination ComparisonTarget *models.ComparisonTarget Position int @@ -167,7 +168,12 @@ func (e *Executor) resolveTaskRepoInfo(ctx context.Context, tr *models.TaskRepos func (e *Executor) resolveTaskRepoInfoForSession( ctx context.Context, sessionID string, tr *models.TaskRepository, ) (*repoInfo, error) { + options, err := models.GetRepositoryCheckoutOptions(tr.Metadata) + if err != nil { + return nil, err + } info := &repoInfo{ + CheckoutOptions: options, TaskRepositoryID: tr.ID, RepositoryID: tr.RepositoryID, BaseBranch: tr.BaseBranch, @@ -215,7 +221,10 @@ func (e *Executor) resolveTaskRepoInfoForSession( } e.resolvePRBaseForLaunch(ctx, tr, repo, info) - remoteRefState, err := e.ensureRepoLocalPathForSessionAndState(ctx, tr.TaskID, sessionID, repo) + // Task checkout modes select a separate cache without rewriting the repository record. + repoCopy := *repo + repo = &repoCopy + remoteRefState, err := e.ensureTaskCheckoutPath(ctx, tr.TaskID, sessionID, repo, options) if err != nil { return nil, err } @@ -255,12 +264,12 @@ func (e *Executor) resolveTaskRepoInfoForSession( prNumber, checkoutBranch := info.PRNumber, info.CheckoutBranch info.RefreshRepository = func(refreshCtx context.Context) error { return e.refreshManagedRepositoryForSession( - refreshCtx, tr.TaskID, sessionID, repo, prNumber, checkoutBranch, + refreshCtx, tr.TaskID, sessionID, repo, prNumber, checkoutBranch, options, ) } info.RefreshRepositoryWithState = func(refreshCtx context.Context) (repoclone.RemoteRefState, error) { return e.refreshManagedRepositoryForSessionWithState( - refreshCtx, tr.TaskID, sessionID, repo, prNumber, checkoutBranch, + refreshCtx, tr.TaskID, sessionID, repo, prNumber, checkoutBranch, options, ) } } @@ -345,16 +354,16 @@ func isPluginManagedRepository(repo *models.Repository) bool { } func (e *Executor) refreshManagedRepositoryForSession( - ctx context.Context, taskID, sessionID string, repo *models.Repository, prNumber int, checkoutBranch string, + ctx context.Context, taskID, sessionID string, repo *models.Repository, prNumber int, checkoutBranch string, options ...*models.RepositoryCheckoutOptions, ) error { _, err := e.refreshManagedRepositoryForSessionWithState( - ctx, taskID, sessionID, repo, prNumber, checkoutBranch, + ctx, taskID, sessionID, repo, prNumber, checkoutBranch, options..., ) return err } func (e *Executor) refreshManagedRepositoryForSessionWithState( - ctx context.Context, taskID, sessionID string, repo *models.Repository, prNumber int, checkoutBranch string, + ctx context.Context, taskID, sessionID string, repo *models.Repository, prNumber int, checkoutBranch string, options ...*models.RepositoryCheckoutOptions, ) (repoclone.RemoteRefState, error) { if e.repoCloner == nil || repo.LocalPath == "" { return repoclone.RemoteRefStateUnknown, errors.New("managed repository refresh is unavailable") @@ -384,6 +393,9 @@ func (e *Executor) refreshManagedRepositoryForSessionWithState( ) } request := repositoryGitCredentialRequest(taskID, sessionID, repo, cloneURL) + if len(options) > 0 { + request.CheckoutOptions = options[0] + } if isGitHubRepository(repo) { request.PRNumber = prNumber request.CheckoutBranch = checkoutBranch diff --git a/apps/backend/internal/orchestrator/executor/repository_checkout_options.go b/apps/backend/internal/orchestrator/executor/repository_checkout_options.go new file mode 100644 index 0000000000..b0193e4380 --- /dev/null +++ b/apps/backend/internal/orchestrator/executor/repository_checkout_options.go @@ -0,0 +1,40 @@ +package executor + +import ( + "context" + "fmt" + + "github.com/kandev/kandev/internal/repoclone" + "github.com/kandev/kandev/internal/task/models" +) + +func (e *Executor) ensureTaskCheckoutPath(ctx context.Context, taskID, sessionID string, repo *models.Repository, options *models.RepositoryCheckoutOptions) (repoclone.RemoteRefState, error) { + if options == nil || (options.DownloadMode == models.DownloadStandard && len(options.SparseDirectories) == 0) { + return e.ensureRepoLocalPathForSessionAndState(ctx, taskID, sessionID, repo) + } + policy, policyErr := e.resolveTaskGitCredentialPolicy(ctx, repo.WorkspaceID) + if policyErr != nil { + return repoclone.RemoteRefStateUnknown, policyErr + } + if policy.Mode != taskGitCredentialsModeManaged { + return repoclone.RemoteRefStateUnknown, fmt.Errorf("repository checkout options require Kandev-managed Git credentials") + } + if repo.SourceType == sourceTypeLocal || !isGitHubRepository(repo) || e.repoCloner == nil { + return repoclone.RemoteRefStateUnknown, fmt.Errorf("repository checkout options require a managed GitHub repository") + } + cloneURL := repositoryCloneURL(repo) + if cloneURL == "" { + return repoclone.RemoteRefStateUnknown, ErrNoCloneURL + } + request := repositoryGitCredentialRequest(taskID, sessionID, repo, cloneURL) + request.CheckoutOptions = options + cloner, ok := e.repoCloner.(remoteStateRepoCloner) + if !ok { + return repoclone.RemoteRefStateUnknown, fmt.Errorf("repository checkout options are unavailable") + } + path, state, err := cloner.EnsureWorkspaceClonedWithCredentialRequestAndState(ctx, request, "", "") + if err == nil { + repo.LocalPath = path + } + return state, err +} diff --git a/apps/backend/internal/orchestrator/executor/repository_checkout_options_test.go b/apps/backend/internal/orchestrator/executor/repository_checkout_options_test.go new file mode 100644 index 0000000000..9c3271d620 --- /dev/null +++ b/apps/backend/internal/orchestrator/executor/repository_checkout_options_test.go @@ -0,0 +1,30 @@ +package executor + +import ( + "context" + "encoding/json" + "github.com/kandev/kandev/internal/task/models" + "reflect" + "strings" + "testing" +) + +func TestRepositoryCheckoutOptionsReachLaunch(t *testing.T) { + var info repoInfo + if err := json.Unmarshal([]byte(`{"RepositoryID":"repo","CheckoutOptions":{"version":1,"download_mode":"on_demand","sparse_directories":["app"]}}`), &info); err != nil { + t.Fatal(err) + } + specs := buildRepoSpecs([]*repoInfo{&info}) + field := reflect.ValueOf(specs[0]).FieldByName("CheckoutOptions") + if !field.IsValid() || field.IsNil() { + t.Fatal("checkout options lost") + } +} + +func TestRepositoryCheckoutOptionsRejectExecutorCredentialMode(t *testing.T) { + executor := &Executor{githubCredentialPolicyResolver: fakeTaskGitCredentialPolicyResolver{policy: TaskGitCredentialPolicy{Mode: taskGitCredentialsModeExecutor}}} + _, err := executor.ensureTaskCheckoutPath(context.Background(), "task", "session", &models.Repository{WorkspaceID: "workspace", Provider: "github", ProviderOwner: "owner", ProviderName: "repo"}, &models.RepositoryCheckoutOptions{Version: 1, DownloadMode: models.DownloadOnDemand}) + if err == nil || !strings.Contains(err.Error(), "Kandev-managed Git credentials") { + t.Fatalf("credential mode was not rejected: %v", err) + } +} diff --git a/apps/backend/internal/repoclone/checkout_options.go b/apps/backend/internal/repoclone/checkout_options.go new file mode 100644 index 0000000000..3772f9992c --- /dev/null +++ b/apps/backend/internal/repoclone/checkout_options.go @@ -0,0 +1,151 @@ +package repoclone + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/kandev/kandev/internal/task/models" +) + +const checkoutCacheMarker = "kandev-checkout-cache.json" + +type checkoutCacheIdentity struct { + Version int `json:"version"` + Origin string `json:"origin"` + Mode string `json:"mode"` +} + +func hasCheckoutOptions(request GitCredentialRequest) bool { + o := request.CheckoutOptions + return o != nil && (o.Version != 1 || o.DownloadMode != models.DownloadStandard || len(o.SparseDirectories) > 0) +} + +func (c *Cloner) ensureWorkspaceCheckoutCache(ctx context.Context, request GitCredentialRequest, credentialOrigin, token string) (string, RemoteRefState, error) { + options, err := models.NormalizeRepositoryCheckoutOptions(request.CheckoutOptions) + if err != nil { + return "", RemoteRefStateUnknown, err + } + path, err := c.checkoutCachePath(request, options) + if err != nil { + return "", RemoteRefStateUnknown, err + } + cloneURL, auth, err := c.workspaceCloneAuthRequest(ctx, request, credentialOrigin, token) + if err != nil { + return "", RemoteRefStateUnknown, err + } + identity := checkoutCacheIdentity{Version: 1, Origin: cloneURL, Mode: options.DownloadMode} + mu := c.repoMu(path) + mu.Lock() + defer mu.Unlock() + if err := c.ensureCheckoutCache(ctx, path, identity, auth); err != nil { + return path, RemoteRefStateUnknown, err + } + if err := c.preserveCheckoutGitCrypt(ctx, request, path, cloneURL); err != nil { + return path, RemoteRefStateUnknown, err + } + state, err := c.remoteRefState(ctx, cloneURL, auth) + return path, state, err +} + +func (c *Cloner) ensureCheckoutCache(ctx context.Context, path string, identity checkoutCacheIdentity, auth *cloneAuth) error { + if info, err := os.Lstat(path); err == nil { + if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + return errors.New("managed checkout cache path is not a directory") + } + data, err := os.ReadFile(filepath.Join(path, ".git", checkoutCacheMarker)) + var saved checkoutCacheIdentity + if err != nil || json.Unmarshal(data, &saved) != nil || saved != identity { + return errors.New("managed checkout cache is incomplete or incompatible") + } + if err := c.verifyOriginURLLocked(ctx, path, identity.Origin); err != nil { + return err + } + c.fetch(ctx, path, auth) + return nil + } else if !os.IsNotExist(err) { + return err + } + parent := filepath.Dir(path) + if err := os.MkdirAll(parent, 0o755); err != nil { + return err + } + staging, err := os.MkdirTemp(parent, ".clone-") + if err != nil { + return err + } + defer func() { _ = os.RemoveAll(staging) }() + checkout := filepath.Join(staging, "checkout") + if err := c.cloneCheckoutCache(ctx, checkout, identity, auth); err != nil { + return err + } + data, err := json.Marshal(identity) + if err != nil { + return err + } + if err := os.WriteFile(filepath.Join(checkout, ".git", checkoutCacheMarker), data, 0o600); err != nil { + return err + } + return os.Rename(checkout, path) +} + +func (c *Cloner) cloneCheckoutCache(ctx context.Context, path string, identity checkoutCacheIdentity, auth *cloneAuth) error { + args := []string{"clone", "--no-checkout", gitNoTags} + if identity.Mode == models.DownloadOnDemand { + args = append(args, "--filter=blob:none") + } + args = append(args, "--", identity.Origin, path) + out, err := c.runConfiguredGitCombined(ctx, gitCloneTimeout, args, func(cmd *exec.Cmd) (func(), error) { return configureGitCommand(cmd, auth) }) + if err != nil { + return fmt.Errorf("repository cache clone failed: %s: %w", redactCloneOutput(string(out), authToken(auth)), err) + } + if identity.Mode == models.DownloadOnDemand && strings.Contains(strings.ToLower(string(out)), "filtering not recognized") { + return errors.New("repository server does not support on-demand downloads") + } + return nil +} + +func (c *Cloner) checkoutCachePath(request GitCredentialRequest, options *models.RepositoryCheckoutOptions) (string, error) { + standardPath, err := c.WorkspaceProviderRepositoryPath(request.WorkspaceID, request.Provider, request.ProviderHost, request.ProviderScope, request.ProviderRepositoryID, request.Owner, request.Name) + if err != nil { + return "", err + } + base, err := c.ExpandedBasePath() + if err != nil { + return "", err + } + sum := sha256.Sum256([]byte(standardPath)) + return filepath.Join(base, managedWorkspacesDir, request.WorkspaceID, "_checkout_modes", hex.EncodeToString(sum[:]), options.DownloadMode+"-v1"), nil +} + +// The normal managed clone remains the owner of unlock keys and revocation. +func (c *Cloner) preserveCheckoutGitCrypt(ctx context.Context, request GitCredentialRequest, cache, origin string) error { + standard, err := c.WorkspaceProviderRepositoryPath(request.WorkspaceID, request.Provider, request.ProviderHost, request.ProviderScope, request.ProviderRepositoryID, request.Owner, request.Name) + if err != nil { + return err + } + keys := filepath.Join(standard, ".git", "git-crypt") + if _, err := os.Stat(keys); os.IsNotExist(err) { + return nil + } else if err != nil { + return err + } + if err := c.verifyOriginURLLocked(ctx, standard, origin); err != nil { + return err + } + destination := filepath.Join(cache, ".git", "git-crypt") + if target, err := os.Readlink(destination); err == nil && target == keys { + return nil + } + if err := os.Symlink(keys, destination); err != nil { + return fmt.Errorf("retain managed git-crypt state: %w", err) + } + return nil +} diff --git a/apps/backend/internal/repoclone/clone.go b/apps/backend/internal/repoclone/clone.go index af187060ee..adee6e1708 100644 --- a/apps/backend/internal/repoclone/clone.go +++ b/apps/backend/internal/repoclone/clone.go @@ -20,6 +20,7 @@ import ( "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/common/subproc" + "github.com/kandev/kandev/internal/task/models" ) const ( @@ -76,6 +77,7 @@ type GitCredentialProvider interface { // separate so resolvers can enforce same-origin routing before returning a // transient secret. type GitCredentialRequest struct { + CheckoutOptions *models.RepositoryCheckoutOptions `json:"checkout_options,omitempty"` WorkspaceID string TaskID string SessionID string @@ -436,6 +438,10 @@ func (c *Cloner) EnsureWorkspaceClonedForProvider( func (c *Cloner) EnsureWorkspaceClonedWithCredentialRequest( ctx context.Context, request GitCredentialRequest, credentialOrigin, token string, ) (string, error) { + if hasCheckoutOptions(request) { + path, _, err := c.ensureWorkspaceCheckoutCache(ctx, request, credentialOrigin, token) + return path, err + } targetPath, err := c.WorkspaceProviderRepositoryPath( request.WorkspaceID, request.Provider, request.ProviderHost, request.ProviderScope, request.ProviderRepositoryID, request.Owner, request.Name, @@ -464,6 +470,13 @@ func (c *Cloner) RefreshWorkspaceRepositoryWithCredentialRequest( request.WorkspaceID, request.Provider, request.ProviderHost, request.ProviderScope, request.ProviderRepositoryID, request.Owner, request.Name, ) + if err == nil && hasCheckoutOptions(request) { + options, validationErr := models.NormalizeRepositoryCheckoutOptions(request.CheckoutOptions) + if validationErr != nil { + return validationErr + } + targetPath, err = c.checkoutCachePath(request, options) + } if err != nil { return err } diff --git a/apps/backend/internal/repoclone/clone_checkout_options_test.go b/apps/backend/internal/repoclone/clone_checkout_options_test.go new file mode 100644 index 0000000000..7cf48374a8 --- /dev/null +++ b/apps/backend/internal/repoclone/clone_checkout_options_test.go @@ -0,0 +1,156 @@ +package repoclone + +import ( + "context" + "encoding/json" + "net/http" + "net/http/cgi" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "sync/atomic" + "testing" + + "github.com/kandev/kandev/internal/common/logger" +) + +// @covers AC-TASKS-REMOTE-OPTIONS-003.1 +// @covers AC-TASKS-REMOTE-OPTIONS-003.3 +func TestAuthenticatedCloneCheckoutOptionsLazyRead(t *testing.T) { + origin, firstBlob, secondBlob, allowed := checkoutOptionsGitServer(t) + t.Setenv("GIT_SSL_NO_VERIFY", "true") // Disposable test server has its own certificate. + cloner := NewCloner(Config{BasePath: t.TempDir()}, ProtocolHTTPS, "", logger.Default()) + cloner.SetGitCredentialProvider(&recordingCredentialProvider{password: "fixture-token"}) + request := GitCredentialRequest{WorkspaceID: "workspace", Provider: "github", ProviderHost: origin, CloneURL: origin + "/repo.git", Owner: "acme", Name: "repo"} + if err := json.Unmarshal([]byte(`{"checkout_options":{"version":1,"download_mode":"on_demand","sparse_directories":["app"]}}`), &request); err != nil { + t.Fatal(err) + } + path, _, err := cloner.EnsureWorkspaceClonedWithCredentialRequestAndState(context.Background(), request, "", "") + if err != nil { + t.Fatal(err) + } + if _, err := cloner.RefreshWorkspaceRepositoryWithCredentialRequestAndState(context.Background(), request, path, "", ""); err != nil { + t.Fatalf("refresh option-specific cache: %v", err) + } + objects := checkoutOptionsGit(t, path, "cat-file", "--batch-all-objects", "--batch-check=%(objectname)") + if strings.Contains(objects, firstBlob) || strings.Contains(objects, secondBlob) { + t.Fatal("on-demand clone downloaded historical file contents") + } + if _, err := os.Stat(filepath.Join(path, "README.md")); !os.IsNotExist(err) { + t.Fatal("cache clone populated a working tree before task folder selection") + } + read := func(blob string) ([]byte, error) { + return cloner.runConfiguredGitCombined(context.Background(), gitFetchTimeout, []string{"-C", path, "cat-file", "blob", blob}, func(cmd *exec.Cmd) (func(), error) { + return configureGitCommand(cmd, &cloneAuth{origin: origin, username: "x-access-token", password: "fixture-token"}) + }) + } + if out, err := read(firstBlob); err != nil || string(out) != "historical first" { + t.Fatalf("lazy read after clone cleanup: %q, %v", out, err) + } + allowed.Store(false) + if _, err := read(secondBlob); err == nil { + t.Fatal("lazy read succeeded after credential revocation") + } +} + +func checkoutOptionsGitServer(t *testing.T) (string, string, string, *atomic.Bool) { + isolateCheckoutOptionsTestGitEnv(t) + t.Helper() + root := t.TempDir() + source := filepath.Join(root, "source") + if err := os.Mkdir(source, 0o755); err != nil { + t.Fatal(err) + } + checkoutOptionsGit(t, source, "init", "-b", "main") + checkoutOptionsGit(t, source, "config", "user.name", "Fixture") + checkoutOptionsGit(t, source, "config", "user.email", "fixture@example.test") + for name, value := range map[string]string{"README.md": "root", "first.txt": "historical first", "second.txt": "historical second"} { + if err := os.WriteFile(filepath.Join(source, name), []byte(value), 0o600); err != nil { + t.Fatal(err) + } + } + checkoutOptionsGit(t, source, "add", ".") + checkoutOptionsGit(t, source, "-c", "commit.gpgsign=false", "commit", "-m", "history") + first := strings.TrimSpace(checkoutOptionsGit(t, source, "rev-parse", "HEAD:first.txt")) + second := strings.TrimSpace(checkoutOptionsGit(t, source, "rev-parse", "HEAD:second.txt")) + checkoutOptionsGit(t, source, "rm", "first.txt", "second.txt") + checkoutOptionsGit(t, source, "-c", "commit.gpgsign=false", "commit", "-m", "remove historical files") + bare := filepath.Join(root, "repo.git") + checkoutOptionsGit(t, root, "clone", "--bare", source, bare) + checkoutOptionsGit(t, bare, "config", "uploadpack.allowFilter", "true") + checkoutOptionsGit(t, bare, "config", "uploadpack.allowAnySHA1InWant", "true") + git, err := exec.LookPath("git") + if err != nil { + t.Fatal(err) + } + backend := &cgi.Handler{Path: git, Args: []string{"http-backend"}, Env: []string{"GIT_PROJECT_ROOT=" + root, "GIT_HTTP_EXPORT_ALL=1"}} + allowed := &atomic.Bool{} + allowed.Store(true) + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, password, ok := r.BasicAuth() + if !ok || password != "fixture-token" || !allowed.Load() { + w.Header().Set("WWW-Authenticate", `Basic realm="fixture"`) + w.WriteHeader(http.StatusUnauthorized) + return + } + backend.ServeHTTP(w, r) + })) + t.Cleanup(server.Close) + return server.URL, first, second, allowed +} + +func checkoutOptionsGit(t *testing.T, directory string, args ...string) string { + t.Helper() + cmd := exec.Command("git", append([]string{"-C", directory}, args...)...) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %v: %v: %s", args, err, out) + } + return string(out) +} + +func TestAuthenticatedCloneCheckoutOptionsRetainsGitCryptKeys(t *testing.T) { + origin, _, _, _ := checkoutOptionsGitServer(t) + t.Setenv("GIT_SSL_NO_VERIFY", "true") + cloner := NewCloner(Config{BasePath: t.TempDir()}, ProtocolHTTPS, "", logger.Default()) + cloner.SetGitCredentialProvider(&recordingCredentialProvider{password: "fixture-token"}) + request := GitCredentialRequest{WorkspaceID: "workspace", Provider: "github", ProviderHost: origin, CloneURL: origin + "/repo.git", Owner: "acme", Name: "repo"} + standard, err := cloner.EnsureWorkspaceClonedWithCredentialRequest(context.Background(), request, "", "") + if err != nil { + t.Fatal(err) + } + keys := filepath.Join(standard, ".git", "git-crypt", "keys") + if err := os.MkdirAll(keys, 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(keys, "default"), []byte("key-fixture"), 0600); err != nil { + t.Fatal(err) + } + if err := json.Unmarshal([]byte(`{"checkout_options":{"version":1,"download_mode":"on_demand"}}`), &request); err != nil { + t.Fatal(err) + } + cache, err := cloner.EnsureWorkspaceClonedWithCredentialRequest(context.Background(), request, "", "") + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(filepath.Join(cache, ".git", "git-crypt", "keys", "default")) + if err != nil || string(data) != "key-fixture" { + t.Fatalf("managed unlock state lost: %q %v", data, err) + } +} + +func isolateCheckoutOptionsTestGitEnv(t *testing.T) { + t.Helper() + t.Setenv("GIT_CONFIG_GLOBAL", filepath.Join(t.TempDir(), "gitconfig")) + t.Setenv("GIT_CONFIG_NOSYSTEM", "1") + for _, key := range []string{"GIT_DIR", "GIT_WORK_TREE", "GIT_COMMON_DIR", "GIT_INDEX_FILE", "GIT_OBJECT_DIRECTORY", "GIT_ALTERNATE_OBJECT_DIRECTORIES"} { + if value, exists := os.LookupEnv(key); exists { + t.Setenv(key, value) + if err := os.Unsetenv(key); err != nil { + t.Fatal(err) + } + } + } +} diff --git a/apps/backend/internal/repoclone/remote_state.go b/apps/backend/internal/repoclone/remote_state.go index 64e27cb3d4..b63d00fcbc 100644 --- a/apps/backend/internal/repoclone/remote_state.go +++ b/apps/backend/internal/repoclone/remote_state.go @@ -10,6 +10,7 @@ import ( "time" "github.com/kandev/kandev/internal/common/subproc" + "github.com/kandev/kandev/internal/task/models" ) // RemoteRefState describes what an authenticated remote advertised after a @@ -127,6 +128,9 @@ func parseRemoteRefState(output string) (RemoteRefState, error) { func (c *Cloner) EnsureWorkspaceClonedWithCredentialRequestAndState( ctx context.Context, request GitCredentialRequest, credentialOrigin, token string, ) (string, RemoteRefState, error) { + if hasCheckoutOptions(request) { + return c.ensureWorkspaceCheckoutCache(ctx, request, credentialOrigin, token) + } targetPath, err := c.WorkspaceProviderRepositoryPath( request.WorkspaceID, request.Provider, request.ProviderHost, request.ProviderScope, request.ProviderRepositoryID, request.Owner, request.Name, @@ -185,6 +189,13 @@ func (c *Cloner) RefreshWorkspaceRepositoryWithCredentialRequestAndState( request.WorkspaceID, request.Provider, request.ProviderHost, request.ProviderScope, request.ProviderRepositoryID, request.Owner, request.Name, ) + if err == nil && hasCheckoutOptions(request) { + options, validationErr := models.NormalizeRepositoryCheckoutOptions(request.CheckoutOptions) + if validationErr != nil { + return RemoteRefStateUnknown, validationErr + } + targetPath, err = c.checkoutCachePath(request, options) + } if err != nil { return RemoteRefStateUnknown, err } diff --git a/apps/backend/internal/task/dto/dto.go b/apps/backend/internal/task/dto/dto.go index 0e3504db4a..1db50c34dd 100644 --- a/apps/backend/internal/task/dto/dto.go +++ b/apps/backend/internal/task/dto/dto.go @@ -323,20 +323,21 @@ type TaskDTO struct { } type TaskRepositoryDTO struct { - ID string `json:"id"` - TaskID string `json:"task_id"` - RepositoryID string `json:"repository_id"` - BaseBranch string `json:"base_branch"` - CheckoutBranch string `json:"checkout_branch,omitempty"` - BranchPolicyID string `json:"branch_policy_id,omitempty"` - BranchPolicyName string `json:"branch_policy_name,omitempty"` - BranchPolicyBaseBranch string `json:"branch_policy_base_branch,omitempty"` - BranchPolicyBranchTemplate string `json:"branch_policy_branch_template,omitempty"` - BranchPolicyPullRequestTarget string `json:"branch_policy_pull_request_target,omitempty"` - Position int `json:"position"` - Metadata map[string]interface{} `json:"metadata,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + CheckoutOptions *models.RepositoryCheckoutOptions `json:"checkout_options,omitempty"` + ID string `json:"id"` + TaskID string `json:"task_id"` + RepositoryID string `json:"repository_id"` + BaseBranch string `json:"base_branch"` + CheckoutBranch string `json:"checkout_branch,omitempty"` + BranchPolicyID string `json:"branch_policy_id,omitempty"` + BranchPolicyName string `json:"branch_policy_name,omitempty"` + BranchPolicyBaseBranch string `json:"branch_policy_base_branch,omitempty"` + BranchPolicyBranchTemplate string `json:"branch_policy_branch_template,omitempty"` + BranchPolicyPullRequestTarget string `json:"branch_policy_pull_request_target,omitempty"` + Position int `json:"position"` + Metadata map[string]interface{} `json:"metadata,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } // TaskWorkspaceFolderDTO is the API projection of a durable non-Git source. @@ -938,6 +939,7 @@ func FromTaskWithSessionInfo( var repositories []TaskRepositoryDTO for _, repo := range task.Repositories { repositories = append(repositories, TaskRepositoryDTO{ + CheckoutOptions: models.PublicRepositoryCheckoutOptions(repo.Metadata), ID: repo.ID, TaskID: repo.TaskID, RepositoryID: repo.RepositoryID, diff --git a/apps/backend/internal/task/dto/requests.go b/apps/backend/internal/task/dto/requests.go index e18edd15f2..e94e04e034 100644 --- a/apps/backend/internal/task/dto/requests.go +++ b/apps/backend/internal/task/dto/requests.go @@ -255,22 +255,23 @@ type GetTaskRequest struct { } type TaskRepositoryInput struct { - RepositoryID string - BaseBranch string - CheckoutBranch string - BranchPolicyID string - PRNumber int // GitHub PR number when CheckoutBranch is a PR head; persisted into task_repositories.metadata["pr_number"]. - LocalPath string - Name string - DefaultBranch string - GitHubURL string - RemoteURL string - Provider string - ProviderHost string - ProviderScope string - ProviderRepoID string - ProviderOwner string - ProviderName string + CheckoutOptions *models.RepositoryCheckoutOptions + RepositoryID string + BaseBranch string + CheckoutBranch string + BranchPolicyID string + PRNumber int // GitHub PR number when CheckoutBranch is a PR head; persisted into task_repositories.metadata["pr_number"]. + LocalPath string + Name string + DefaultBranch string + GitHubURL string + RemoteURL string + Provider string + ProviderHost string + ProviderScope string + ProviderRepoID string + ProviderOwner string + ProviderName string // PreserveBaseBranch is set only by the internal fresh-branch rewrite. It // keeps the generated branch as the effective base when the association is // recreated after policy resolution. diff --git a/apps/backend/internal/task/handlers/repository_checkout_capabilities.go b/apps/backend/internal/task/handlers/repository_checkout_capabilities.go new file mode 100644 index 0000000000..61dec264c1 --- /dev/null +++ b/apps/backend/internal/task/handlers/repository_checkout_capabilities.go @@ -0,0 +1,25 @@ +package handlers + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/kandev/kandev/internal/task/service" +) + +func (h *RepositoryHandlers) httpRepositoryCheckoutCapabilities(c *gin.Context) { + var request struct { + Repository service.TaskRepositoryInput `json:"repository"` + ExecutorProfileID string `json:"executor_profile_id"` + } + if err := c.ShouldBindJSON(&request); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid repository checkout capability request"}) + return + } + capabilities, err := h.service.GetRepositoryCheckoutCapabilities(c.Request.Context(), c.Param("id"), request.Repository, request.ExecutorProfileID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "repository checkout capabilities unavailable", "code": "checkout_capabilities_unavailable"}) + return + } + c.JSON(http.StatusOK, capabilities) +} diff --git a/apps/backend/internal/task/handlers/repository_checkout_options_test.go b/apps/backend/internal/task/handlers/repository_checkout_options_test.go new file mode 100644 index 0000000000..51a8fb5ca7 --- /dev/null +++ b/apps/backend/internal/task/handlers/repository_checkout_options_test.go @@ -0,0 +1,52 @@ +package handlers + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" +) + +func TestRepositoryCheckoutOptionsRequestConversion(t *testing.T) { + var input httpTaskRepositoryInput + if err := json.Unmarshal([]byte(`{"remote_url":"https://github.com/acme/repo","checkout_options":{"version":1,"download_mode":"on_demand","sparse_directories":["app"]}}`), &input); err != nil { + t.Fatal(err) + } + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + repos, ok := convertCreateTaskRepositories(c, []httpTaskRepositoryInput{input}) + if !ok { + t.Fatal("request rejected") + } + result := convertToServiceRepos(repos) + if len(result) != 1 || result[0].CheckoutOptions == nil { + t.Fatal("request conversion lost checkout options") + } + if result[0].CheckoutOptions.SparseDirectories[0] != "app" { + t.Fatal("request conversion changed folder selection") + } +} + +func TestCheckoutCapabilitiesHTTP(t *testing.T) { + router, _ := newRepositoryHTTPTestRouter(t) + request := httptest.NewRequest(http.MethodPost, "/api/v1/workspaces/ws-1/repository-checkout-capabilities", bytes.NewBufferString(`{"repository":{"remote_url":"https://github.com/acme/repo"}}`)) + request.Header.Set("Content-Type", "application/json") + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + if response.Code != http.StatusOK { + t.Fatalf("capabilities status = %d: %s", response.Code, response.Body.String()) + } + var capabilities struct { + OnDemand bool `json:"on_demand"` + Sparse bool `json:"sparse"` + Reason string `json:"reason"` + } + if err := json.Unmarshal(response.Body.Bytes(), &capabilities); err != nil { + t.Fatal(err) + } + if capabilities.OnDemand || capabilities.Sparse || capabilities.Reason != "executor_required" { + t.Fatalf("unsupported capabilities = %+v", capabilities) + } +} diff --git a/apps/backend/internal/task/handlers/repository_checkout_profiles_test.go b/apps/backend/internal/task/handlers/repository_checkout_profiles_test.go new file mode 100644 index 0000000000..ec35cc303c --- /dev/null +++ b/apps/backend/internal/task/handlers/repository_checkout_profiles_test.go @@ -0,0 +1,45 @@ +package handlers + +import ( + "github.com/gin-gonic/gin" + "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/task/service" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestHTTPCreateTaskExecutorProfileWithoutAgent(t *testing.T) { + gin.SetMode(gin.TestMode) + log := newTestLogger(t) + + repo := &captureCreateTaskRepo{} + svc := service.NewService(service.Repos{ + Workspaces: repo, Tasks: repo, TaskRepos: repo, + Workflows: repo, Messages: repo, Turns: repo, + Sessions: repo, GitSnapshots: repo, RepoEntities: repo, + Executors: repo, Environments: repo, TaskEnvironments: repo, + Reviews: repo, + }, nil, log, service.RepositoryDiscoveryConfig{}) + h := &TaskHandlers{service: svc, logger: log} + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/tasks", strings.NewReader(`{ + "workspace_id": "ws-1", + "title": "Analyse integrations", + "project_id": "proj-1", + "priority": "medium", "executor_profile_id": "executor-profile" + }`)) + c.Request.Header.Set("Content-Type", "application/json") + + h.httpCreateTask(c) + + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + require.NotNil(t, repo.captured, "service.CreateTask was not called") + assert.Equal(t, "executor-profile", repo.captured.Metadata[models.MetaKeyExecutorProfileID]) + assert.Equal(t, "wf-office", repo.captured.WorkflowID, "office workflow should be auto-resolved") +} diff --git a/apps/backend/internal/task/handlers/repository_handlers.go b/apps/backend/internal/task/handlers/repository_handlers.go index 5f9a8b3f80..653fc6f675 100644 --- a/apps/backend/internal/task/handlers/repository_handlers.go +++ b/apps/backend/internal/task/handlers/repository_handlers.go @@ -38,6 +38,7 @@ func RegisterRepositoryRoutes(router *gin.Engine, dispatcher *ws.Dispatcher, svc func (h *RepositoryHandlers) registerHTTP(router *gin.Engine) { api := router.Group("/api/v1") + api.POST("/workspaces/:id/repository-checkout-capabilities", h.httpRepositoryCheckoutCapabilities) api.GET("/workspaces/:id/repositories", h.httpListRepositories) api.POST("/workspaces/:id/repositories", h.httpCreateRepository) api.POST("/workspaces/:id/repositories/initialize-local", h.httpInitializeLocalRepository) diff --git a/apps/backend/internal/task/handlers/task_handlers.go b/apps/backend/internal/task/handlers/task_handlers.go index 0a54f84ec5..71f330458f 100644 --- a/apps/backend/internal/task/handlers/task_handlers.go +++ b/apps/backend/internal/task/handlers/task_handlers.go @@ -291,6 +291,7 @@ func convertToServiceRepos(repos []dto.TaskRepositoryInput) []service.TaskReposi result := make([]service.TaskRepositoryInput, len(repos)) for i, r := range repos { result[i] = service.TaskRepositoryInput{ + CheckoutOptions: r.CheckoutOptions, RepositoryID: r.RepositoryID, BaseBranch: r.BaseBranch, CheckoutBranch: r.CheckoutBranch, diff --git a/apps/backend/internal/task/handlers/task_http_handlers.go b/apps/backend/internal/task/handlers/task_http_handlers.go index 989282d97b..a1b8e26544 100644 --- a/apps/backend/internal/task/handlers/task_http_handlers.go +++ b/apps/backend/internal/task/handlers/task_http_handlers.go @@ -721,22 +721,23 @@ func (h *TaskHandlers) httpBulkMoveSelectedTasks(c *gin.Context, body httpBulkMo } type httpTaskRepositoryInput struct { - RepositoryID string `json:"repository_id"` - BaseBranch string `json:"base_branch"` - CheckoutBranch string `json:"checkout_branch"` - BranchPolicyID string `json:"branch_policy_id,omitempty"` - PRNumber int `json:"pr_number,omitempty"` - LocalPath string `json:"local_path"` - Name string `json:"name"` - DefaultBranch string `json:"default_branch"` - GitHubURL string `json:"github_url"` - RemoteURL string `json:"remote_url"` - Provider string `json:"provider"` - ProviderHost string `json:"provider_host"` - ProviderScope string `json:"provider_scope"` - ProviderRepoID string `json:"provider_repo_id"` - ProviderOwner string `json:"provider_owner"` - ProviderName string `json:"provider_name"` + CheckoutOptions *models.RepositoryCheckoutOptions `json:"checkout_options,omitempty"` + RepositoryID string `json:"repository_id"` + BaseBranch string `json:"base_branch"` + CheckoutBranch string `json:"checkout_branch"` + BranchPolicyID string `json:"branch_policy_id,omitempty"` + PRNumber int `json:"pr_number,omitempty"` + LocalPath string `json:"local_path"` + Name string `json:"name"` + DefaultBranch string `json:"default_branch"` + GitHubURL string `json:"github_url"` + RemoteURL string `json:"remote_url"` + Provider string `json:"provider"` + ProviderHost string `json:"provider_host"` + ProviderScope string `json:"provider_scope"` + ProviderRepoID string `json:"provider_repo_id"` + ProviderOwner string `json:"provider_owner"` + ProviderName string `json:"provider_name"` // Fresh-branch flow (local executor only): when FreshBranch is true the // handler discards uncommitted changes in the local clone and creates @@ -930,9 +931,12 @@ func (h *TaskHandlers) httpCreateTask(c *gin.Context) { body.Metadata = make(map[string]interface{}) } body.Metadata[models.MetaKeyAgentProfileID] = body.AgentProfileID - if body.ExecutorProfileID != "" { - body.Metadata[models.MetaKeyExecutorProfileID] = body.ExecutorProfileID + } + if body.ExecutorProfileID != "" { + if body.Metadata == nil { + body.Metadata = make(map[string]interface{}) } + body.Metadata[models.MetaKeyExecutorProfileID] = body.ExecutorProfileID } title := strings.TrimSpace(body.Title) @@ -1372,22 +1376,23 @@ func convertCreateTaskRepositories(c *gin.Context, inputs []httpTaskRepositoryIn return nil, false } repos = append(repos, dto.TaskRepositoryInput{ - RepositoryID: r.RepositoryID, - BaseBranch: r.BaseBranch, - CheckoutBranch: r.CheckoutBranch, - BranchPolicyID: r.BranchPolicyID, - PRNumber: r.PRNumber, - LocalPath: r.LocalPath, - Name: r.Name, - DefaultBranch: r.DefaultBranch, - GitHubURL: r.GitHubURL, - RemoteURL: r.RemoteURL, - Provider: r.Provider, - ProviderHost: r.ProviderHost, - ProviderScope: r.ProviderScope, - ProviderRepoID: r.ProviderRepoID, - ProviderOwner: r.ProviderOwner, - ProviderName: r.ProviderName, + CheckoutOptions: r.CheckoutOptions, + RepositoryID: r.RepositoryID, + BaseBranch: r.BaseBranch, + CheckoutBranch: r.CheckoutBranch, + BranchPolicyID: r.BranchPolicyID, + PRNumber: r.PRNumber, + LocalPath: r.LocalPath, + Name: r.Name, + DefaultBranch: r.DefaultBranch, + GitHubURL: r.GitHubURL, + RemoteURL: r.RemoteURL, + Provider: r.Provider, + ProviderHost: r.ProviderHost, + ProviderScope: r.ProviderScope, + ProviderRepoID: r.ProviderRepoID, + ProviderOwner: r.ProviderOwner, + ProviderName: r.ProviderName, }) } return repos, true @@ -1635,22 +1640,23 @@ func (h *TaskHandlers) httpUpdateTask(c *gin.Context) { if body.Repositories != nil { for _, r := range body.Repositories { repos = append(repos, dto.TaskRepositoryInput{ - RepositoryID: r.RepositoryID, - BaseBranch: r.BaseBranch, - CheckoutBranch: r.CheckoutBranch, - BranchPolicyID: r.BranchPolicyID, - PRNumber: r.PRNumber, - LocalPath: r.LocalPath, - Name: r.Name, - DefaultBranch: r.DefaultBranch, - GitHubURL: r.GitHubURL, - RemoteURL: r.RemoteURL, - Provider: r.Provider, - ProviderHost: r.ProviderHost, - ProviderScope: r.ProviderScope, - ProviderRepoID: r.ProviderRepoID, - ProviderOwner: r.ProviderOwner, - ProviderName: r.ProviderName, + CheckoutOptions: r.CheckoutOptions, + RepositoryID: r.RepositoryID, + BaseBranch: r.BaseBranch, + CheckoutBranch: r.CheckoutBranch, + BranchPolicyID: r.BranchPolicyID, + PRNumber: r.PRNumber, + LocalPath: r.LocalPath, + Name: r.Name, + DefaultBranch: r.DefaultBranch, + GitHubURL: r.GitHubURL, + RemoteURL: r.RemoteURL, + Provider: r.Provider, + ProviderHost: r.ProviderHost, + ProviderScope: r.ProviderScope, + ProviderRepoID: r.ProviderRepoID, + ProviderOwner: r.ProviderOwner, + ProviderName: r.ProviderName, }) } } diff --git a/apps/backend/internal/task/handlers/task_ws_handlers.go b/apps/backend/internal/task/handlers/task_ws_handlers.go index c6c606dfcf..4c52be2c34 100644 --- a/apps/backend/internal/task/handlers/task_ws_handlers.go +++ b/apps/backend/internal/task/handlers/task_ws_handlers.go @@ -133,22 +133,23 @@ func (h *TaskHandlers) wsCreateTask(ctx context.Context, msg *ws.Message) (*ws.M return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "repository_id, local_path, or remote_url is required", nil) } repos = append(repos, dto.TaskRepositoryInput{ - RepositoryID: r.RepositoryID, - BaseBranch: r.BaseBranch, - CheckoutBranch: r.CheckoutBranch, - BranchPolicyID: r.BranchPolicyID, - PRNumber: r.PRNumber, - LocalPath: r.LocalPath, - Name: r.Name, - DefaultBranch: r.DefaultBranch, - GitHubURL: r.GitHubURL, - RemoteURL: r.RemoteURL, - Provider: r.Provider, - ProviderHost: r.ProviderHost, - ProviderScope: r.ProviderScope, - ProviderRepoID: r.ProviderRepoID, - ProviderOwner: r.ProviderOwner, - ProviderName: r.ProviderName, + CheckoutOptions: r.CheckoutOptions, + RepositoryID: r.RepositoryID, + BaseBranch: r.BaseBranch, + CheckoutBranch: r.CheckoutBranch, + BranchPolicyID: r.BranchPolicyID, + PRNumber: r.PRNumber, + LocalPath: r.LocalPath, + Name: r.Name, + DefaultBranch: r.DefaultBranch, + GitHubURL: r.GitHubURL, + RemoteURL: r.RemoteURL, + Provider: r.Provider, + ProviderHost: r.ProviderHost, + ProviderScope: r.ProviderScope, + ProviderRepoID: r.ProviderRepoID, + ProviderOwner: r.ProviderOwner, + ProviderName: r.ProviderName, }) } @@ -161,9 +162,12 @@ func (h *TaskHandlers) wsCreateTask(ctx context.Context, msg *ws.Message) (*ws.M req.Metadata = make(map[string]interface{}) } req.Metadata[models.MetaKeyAgentProfileID] = req.AgentProfileID - if req.ExecutorProfileID != "" { - req.Metadata[models.MetaKeyExecutorProfileID] = req.ExecutorProfileID + } + if req.ExecutorProfileID != "" { + if req.Metadata == nil { + req.Metadata = make(map[string]interface{}) } + req.Metadata[models.MetaKeyExecutorProfileID] = req.ExecutorProfileID } title := strings.TrimSpace(req.Title) @@ -319,22 +323,23 @@ func (h *TaskHandlers) wsUpdateTask(ctx context.Context, msg *ws.Message) (*ws.M if req.Repositories != nil { for _, r := range req.Repositories { repos = append(repos, dto.TaskRepositoryInput{ - RepositoryID: r.RepositoryID, - BaseBranch: r.BaseBranch, - CheckoutBranch: r.CheckoutBranch, - BranchPolicyID: r.BranchPolicyID, - PRNumber: r.PRNumber, - LocalPath: r.LocalPath, - Name: r.Name, - DefaultBranch: r.DefaultBranch, - GitHubURL: r.GitHubURL, - RemoteURL: r.RemoteURL, - Provider: r.Provider, - ProviderHost: r.ProviderHost, - ProviderScope: r.ProviderScope, - ProviderRepoID: r.ProviderRepoID, - ProviderOwner: r.ProviderOwner, - ProviderName: r.ProviderName, + CheckoutOptions: r.CheckoutOptions, + RepositoryID: r.RepositoryID, + BaseBranch: r.BaseBranch, + CheckoutBranch: r.CheckoutBranch, + BranchPolicyID: r.BranchPolicyID, + PRNumber: r.PRNumber, + LocalPath: r.LocalPath, + Name: r.Name, + DefaultBranch: r.DefaultBranch, + GitHubURL: r.GitHubURL, + RemoteURL: r.RemoteURL, + Provider: r.Provider, + ProviderHost: r.ProviderHost, + ProviderScope: r.ProviderScope, + ProviderRepoID: r.ProviderRepoID, + ProviderOwner: r.ProviderOwner, + ProviderName: r.ProviderName, }) } } diff --git a/apps/backend/internal/task/handlers/task_ws_handlers_test.go b/apps/backend/internal/task/handlers/task_ws_handlers_test.go index 74da57496a..95a6ff1072 100644 --- a/apps/backend/internal/task/handlers/task_ws_handlers_test.go +++ b/apps/backend/internal/task/handlers/task_ws_handlers_test.go @@ -603,3 +603,15 @@ func TestWSCreateTaskDoesNotWaitForRecentUsePersistence(t *testing.T) { t.Fatal("successful task.create waited for best-effort recent-use persistence") } } + +func TestWSCreateTaskExecutorProfileWithoutAgent(t *testing.T) { + repo := &wsTaskRepo{} + h := newWSTaskHandlers(t, repo) + resp, err := h.wsCreateTask(context.Background(), wsWorkflowRequest(t, ws.ActionTaskCreate, map[string]any{ + "workspace_id": "ws-b", "workflow_id": "wf-b", "title": "Deferred task", "executor_profile_id": "executor-profile", + })) + require.NoError(t, err) + require.Equal(t, ws.MessageTypeResponse, resp.Type, "body: %s", resp.Payload) + require.Len(t, repo.created, 1) + assert.Equal(t, "executor-profile", repo.created[0].Metadata[models.MetaKeyExecutorProfileID]) +} diff --git a/apps/backend/internal/task/models/models.go b/apps/backend/internal/task/models/models.go index 12e7dbea42..080722cb4e 100644 --- a/apps/backend/internal/task/models/models.go +++ b/apps/backend/internal/task/models/models.go @@ -2881,14 +2881,15 @@ func (t *Task) ToAPI() *v1.Task { var repositories []v1.TaskRepository for _, repo := range t.Repositories { repositories = append(repositories, v1.TaskRepository{ - ID: repo.ID, - TaskID: repo.TaskID, - RepositoryID: repo.RepositoryID, - BaseBranch: repo.BaseBranch, - Position: repo.Position, - Metadata: repo.Metadata, - CreatedAt: repo.CreatedAt, - UpdatedAt: repo.UpdatedAt, + CheckoutOptions: PublicRepositoryCheckoutOptions(repo.Metadata), + ID: repo.ID, + TaskID: repo.TaskID, + RepositoryID: repo.RepositoryID, + BaseBranch: repo.BaseBranch, + Position: repo.Position, + Metadata: repo.Metadata, + CreatedAt: repo.CreatedAt, + UpdatedAt: repo.UpdatedAt, }) } diff --git a/apps/backend/internal/task/models/repository_checkout_options.go b/apps/backend/internal/task/models/repository_checkout_options.go new file mode 100644 index 0000000000..d0d09e2feb --- /dev/null +++ b/apps/backend/internal/task/models/repository_checkout_options.go @@ -0,0 +1,96 @@ +package models + +import ( + "encoding/json" + "errors" + "fmt" + "strings" + "unicode" + "unicode/utf8" + + v1 "github.com/kandev/kandev/pkg/api/v1" +) + +const ( + RepositoryCheckoutOptionsKey = "checkout_options" + DownloadStandard = "standard" + DownloadOnDemand = "on_demand" +) + +type RepositoryCheckoutOptions = v1.RepositoryCheckoutOptions + +// NormalizeRepositoryCheckoutOptions validates and copies task-owned options. +func NormalizeRepositoryCheckoutOptions(options *RepositoryCheckoutOptions) (*RepositoryCheckoutOptions, error) { + if options == nil { + return nil, nil + } + if options.Version != 1 { + return nil, errors.New("unsupported checkout options version") + } + if options.DownloadMode != DownloadStandard && options.DownloadMode != DownloadOnDemand { + return nil, errors.New("unsupported repository download mode") + } + encoded, err := json.Marshal(options) + if err != nil || len(encoded) > 16*1024 || len(options.SparseDirectories) > 64 { + return nil, errors.New("checkout options exceed size limit") + } + out := &RepositoryCheckoutOptions{Version: 1, DownloadMode: options.DownloadMode, SparseDirectories: []string{}} + seen := make(map[string]bool) + for _, directory := range options.SparseDirectories { + if err := validateCheckoutDirectory(directory); err != nil { + return nil, err + } + if !seen[directory] { + out.SparseDirectories = append(out.SparseDirectories, directory) + seen[directory] = true + } + } + return out, nil +} + +func validateCheckoutDirectory(directory string) error { + if directory == "" || len(directory) > 4096 || !utf8.ValidString(directory) || + strings.ContainsAny(directory, "\\:*?[]{}") || strings.IndexFunc(directory, unicode.IsControl) >= 0 { + return errors.New("invalid repository-relative checkout directory") + } + for _, segment := range strings.Split(directory, "/") { + if segment == "" || segment == "." || segment == ".." { + return errors.New("checkout directory must stay inside the repository") + } + } + return nil +} + +func PutRepositoryCheckoutOptions(metadata map[string]interface{}, options *RepositoryCheckoutOptions) error { + value, err := NormalizeRepositoryCheckoutOptions(options) + if err != nil { + return err + } + if value != nil { + metadata[RepositoryCheckoutOptionsKey] = value + } + return nil +} + +func GetRepositoryCheckoutOptions(metadata map[string]interface{}) (*RepositoryCheckoutOptions, error) { + raw, exists := metadata[RepositoryCheckoutOptionsKey] + if !exists || raw == nil { + return nil, nil + } + encoded, err := json.Marshal(raw) + if err != nil || len(encoded) > 16*1024 { + return nil, errors.New("invalid stored checkout options") + } + var options RepositoryCheckoutOptions + if err := json.Unmarshal(encoded, &options); err != nil { + return nil, fmt.Errorf("invalid stored checkout options: %w", err) + } + return NormalizeRepositoryCheckoutOptions(&options) +} + +// PublicRepositoryCheckoutOptions omits malformed metadata from the typed view. +// Runtime consumers use GetRepositoryCheckoutOptions and propagate its error. +func PublicRepositoryCheckoutOptions(metadata map[string]interface{}) *RepositoryCheckoutOptions { + options, _ := GetRepositoryCheckoutOptions(metadata) + return options +} diff --git a/apps/backend/internal/task/models/repository_checkout_options_test.go b/apps/backend/internal/task/models/repository_checkout_options_test.go new file mode 100644 index 0000000000..dfdadbfd32 --- /dev/null +++ b/apps/backend/internal/task/models/repository_checkout_options_test.go @@ -0,0 +1,56 @@ +package models + +import ( + "encoding/json" + "reflect" + "strings" + "testing" +) + +func TestRepositoryCheckoutOptionsAPIView(t *testing.T) { + metadata := map[string]interface{}{} + want := &RepositoryCheckoutOptions{Version: 1, DownloadMode: DownloadOnDemand, SparseDirectories: []string{"app"}} + if err := PutRepositoryCheckoutOptions(metadata, want); err != nil { + t.Fatal(err) + } + task := &Task{Repositories: []*TaskRepository{{Metadata: metadata}}} + if got := task.ToAPI().Repositories[0].CheckoutOptions; !reflect.DeepEqual(got, want) { + t.Fatalf("API options = %+v, want %+v", got, want) + } +} + +func TestRepositoryCheckoutOptionsNormalization(t *testing.T) { + for _, directory := range []string{"", "/tmp", "../a", "a/../b", "a//b", "a/./b", "a/", "a\\b", "C:/tmp", "a\nb", "a\x00b", "app/*", strings.Repeat("x", 4097)} { + t.Run(directory, func(t *testing.T) { + _, err := NormalizeRepositoryCheckoutOptions(&RepositoryCheckoutOptions{Version: 1, DownloadMode: DownloadStandard, SparseDirectories: []string{directory}}) + if err == nil { + t.Fatal("invalid directory accepted") + } + }) + } + want := &RepositoryCheckoutOptions{Version: 1, DownloadMode: DownloadOnDemand, SparseDirectories: []string{"app one", "app two"}} + in := &RepositoryCheckoutOptions{Version: 1, DownloadMode: DownloadOnDemand, SparseDirectories: []string{"app one", "app two", "app one"}} + got, err := NormalizeRepositoryCheckoutOptions(in) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("normalized options = %+v", got) + } + in.SparseDirectories[0] = "mutated" + if got.SparseDirectories[0] != "app one" { + t.Fatal("normalization shares input storage") + } + encoded, err := json.Marshal(map[string]interface{}{RepositoryCheckoutOptionsKey: got}) + if err != nil { + t.Fatal(err) + } + var stored map[string]interface{} + if err := json.Unmarshal(encoded, &stored); err != nil { + t.Fatal(err) + } + decoded, err := GetRepositoryCheckoutOptions(stored) + if err != nil || !reflect.DeepEqual(decoded, want) { + t.Fatalf("stored options = %+v, err %v", decoded, err) + } +} diff --git a/apps/backend/internal/task/service/repository_checkout_capabilities.go b/apps/backend/internal/task/service/repository_checkout_capabilities.go new file mode 100644 index 0000000000..d14424b3e6 --- /dev/null +++ b/apps/backend/internal/task/service/repository_checkout_capabilities.go @@ -0,0 +1,113 @@ +package service + +import ( + "context" + "fmt" + + "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/task/repository/repoerrors" +) + +type RepositoryCheckoutCapabilities struct { + OnDemand bool `json:"on_demand"` + Sparse bool `json:"sparse"` + Reason string `json:"reason,omitempty"` +} + +type repositoryCheckoutCapabilityProber interface { + SupportsRepositoryCheckoutOptions(executorType, prepareScript string) (onDemand, sparse bool) +} + +func (s *Service) GetRepositoryCheckoutCapabilities(ctx context.Context, workspaceID string, input TaskRepositoryInput, profileID string) (RepositoryCheckoutCapabilities, error) { + unsupported := RepositoryCheckoutCapabilities{} + if _, err := s.GetWorkspace(ctx, workspaceID); err != nil { + return unsupported, err + } + provider, err := s.checkoutOptionsProvider(ctx, workspaceID, input) + if err != nil { + return unsupported, err + } + if provider != githubProviderName { + unsupported.Reason = "provider_unsupported" + return unsupported, nil + } + if profileID == "" { + unsupported.Reason = "executor_required" + return unsupported, nil + } + profile, err := s.executors.GetExecutorProfile(ctx, profileID) + if err != nil { + return unsupported, err + } + executor, err := s.executors.GetExecutor(ctx, profile.ExecutorID) + if err != nil { + return unsupported, err + } + if s.checkoutCredentialPolicy != nil { + managed, err := s.checkoutCredentialPolicy(ctx, workspaceID) + if err != nil { + return unsupported, err + } + if !managed { + unsupported.Reason = "credentials_unsupported" + return unsupported, nil + } + } + if prober, ok := s.executorCapabilityProber.(repositoryCheckoutCapabilityProber); ok { + unsupported.OnDemand, unsupported.Sparse = prober.SupportsRepositoryCheckoutOptions(string(executor.Type), profile.PrepareScript) + } + if !unsupported.OnDemand || !unsupported.Sparse { + unsupported.Reason = "preparation_unsupported" + } + return unsupported, nil +} + +func (s *Service) checkoutOptionsProvider(ctx context.Context, workspaceID string, input TaskRepositoryInput) (string, error) { + if err := s.validateRepositoryCheckoutInput(ctx, workspaceID, input); err != nil { + return "", err + } + if input.LocalPath != "" { + return sourceTypeLocal, nil + } + if input.RepositoryID != "" { + repo, err := s.repoEntities.GetRepository(ctx, input.RepositoryID) + if err != nil { + return "", err + } + if repo == nil || repo.WorkspaceID != workspaceID { + return "", repoerrors.ErrRepositoryNotFound + } + if repo.SourceType == sourceTypeLocal { + return sourceTypeLocal, nil + } + return repo.Provider, nil + } + provider, _, _, _, err := parseRemoteRepositoryURL(effectiveRemoteURL(input), input.Provider) + return provider, err +} + +func (s *Service) validateTaskCheckoutCapabilities(ctx context.Context, req *CreateTaskRequest) error { + profileID, _ := req.Metadata[models.MetaKeyExecutorProfileID].(string) + for _, input := range req.Repositories { + options, err := models.NormalizeRepositoryCheckoutOptions(input.CheckoutOptions) + if err != nil { + return err + } + if options == nil || (options.DownloadMode == models.DownloadStandard && len(options.SparseDirectories) == 0) { + continue + } + capabilities, err := s.GetRepositoryCheckoutCapabilities(ctx, req.WorkspaceID, input, profileID) + if err != nil { + return err + } + if options.DownloadMode == models.DownloadOnDemand && !capabilities.OnDemand || len(options.SparseDirectories) > 0 && !capabilities.Sparse { + return fmt.Errorf("repository checkout options unavailable: %s", capabilities.Reason) + } + } + return nil +} + +// SetRepositoryCheckoutCredentialPolicy provides integration-owned credential routing. +func (s *Service) SetRepositoryCheckoutCredentialPolicy(policy func(context.Context, string) (bool, error)) { + s.checkoutCredentialPolicy = policy +} diff --git a/apps/backend/internal/task/service/repository_checkout_options.go b/apps/backend/internal/task/service/repository_checkout_options.go new file mode 100644 index 0000000000..ba4d41061d --- /dev/null +++ b/apps/backend/internal/task/service/repository_checkout_options.go @@ -0,0 +1,96 @@ +package service + +import ( + "context" + "errors" + "reflect" + + "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/task/repository/repoerrors" +) + +func (s *Service) validateRepositoryCheckoutInput(ctx context.Context, workspaceID string, input TaskRepositoryInput) error { + if input.CheckoutOptions == nil { + return nil + } + if _, err := models.NormalizeRepositoryCheckoutOptions(input.CheckoutOptions); err != nil { + return err + } + if input.LocalPath != "" { + return errors.New("checkout options require a remote repository") + } + if input.RepositoryID == "" { + return nil + } + repository, err := s.repoEntities.GetRepository(ctx, input.RepositoryID) + if err != nil { + return err + } + if repository == nil || repository.WorkspaceID != workspaceID { + return repoerrors.ErrRepositoryNotFound + } + if repository.SourceType == "local" { + return errors.New("checkout options cannot modify a user-managed local repository") + } + return nil +} + +func (s *Service) preserveRepositoryCheckoutOptions(ctx context.Context, task *models.Task, inputs []TaskRepositoryInput) error { + existing, err := s.taskRepos.ListTaskRepositories(ctx, task.ID) + if err != nil { + return err + } + for i := range inputs { + prior, err := matchingRepositoryCheckoutOptions(inputs[i], existing) + if err != nil { + return err + } + if inputs[i].CheckoutOptions == nil { + inputs[i].CheckoutOptions = prior + continue + } + next, err := models.NormalizeRepositoryCheckoutOptions(inputs[i].CheckoutOptions) + if err != nil { + return err + } + if reflect.DeepEqual(prior, next) { + continue + } + if s.taskEnvironments == nil { + return errors.New("checkout option mutability is unavailable") + } + environment, err := s.taskEnvironments.GetTaskEnvironmentByTaskID(ctx, task.ID) + if err != nil && !errors.Is(err, repoerrors.ErrTaskEnvironmentNotFound) { + return err + } + if environment != nil { + return errors.New("checkout options cannot change after environment creation") + } + if err := s.validateTaskCheckoutCapabilities(ctx, &CreateTaskRequest{WorkspaceID: task.WorkspaceID, Metadata: task.Metadata, Repositories: []TaskRepositoryInput{inputs[i]}}); err != nil { + return err + } + } + return nil +} + +func matchingRepositoryCheckoutOptions(input TaskRepositoryInput, existing []*models.TaskRepository) (*models.RepositoryCheckoutOptions, error) { + var candidate *models.TaskRepository + matches := 0 + for _, row := range existing { + if row == nil || row.RepositoryID != input.RepositoryID { + continue + } + candidate = row + matches++ + if row.BaseBranch == input.BaseBranch && row.CheckoutBranch == input.CheckoutBranch { + return models.GetRepositoryCheckoutOptions(row.Metadata) + } + } + if matches > 1 { + return nil, errors.New("ambiguous repository attachment: specify base and checkout branches") + } + if matches == 1 { + return models.GetRepositoryCheckoutOptions(candidate.Metadata) + } + return nil, nil +} diff --git a/apps/backend/internal/task/service/repository_checkout_options_test.go b/apps/backend/internal/task/service/repository_checkout_options_test.go new file mode 100644 index 0000000000..aef8bf4d6c --- /dev/null +++ b/apps/backend/internal/task/service/repository_checkout_options_test.go @@ -0,0 +1,131 @@ +package service + +import ( + "context" + "encoding/json" + "testing" + + "github.com/kandev/kandev/internal/task/models" +) + +// @covers AC-TASKS-REMOTE-OPTIONS-002.1 +func TestRepositoryCheckoutOptionsMetadata(t *testing.T) { + var input TaskRepositoryInput + if err := json.Unmarshal([]byte(`{"checkout_options":{"version":1,"download_mode":"on_demand","sparse_directories":["extensions/example"]}}`), &input); err != nil { + t.Fatal(err) + } + metadata, err := buildTaskRepositoryMetadata(input) + if err != nil { + t.Fatal(err) + } + encoded, err := json.Marshal(metadata["checkout_options"]) + if err != nil { + t.Fatal(err) + } + if string(encoded) != `{"version":1,"download_mode":"on_demand","sparse_directories":["extensions/example"]}` { + t.Fatalf("checkout options lost in metadata: %s", encoded) + } +} + +func TestRepositoryCheckoutOptionsPreservedOnUpdate(t *testing.T) { + svc, _, repo := createTestService(t) + setupTestTask(t, repo) + ctx := context.Background() + if err := repo.CreateRepository(ctx, &models.Repository{ID: "remote-options", WorkspaceID: "ws-1", Name: "Remote", SourceType: "github", Provider: "github", ProviderOwner: "acme", ProviderName: "repo", DefaultBranch: "main"}); err != nil { + t.Fatal(err) + } + metadata := map[string]interface{}{} + if err := models.PutRepositoryCheckoutOptions(metadata, &models.RepositoryCheckoutOptions{Version: 1, DownloadMode: models.DownloadOnDemand, SparseDirectories: []string{"app"}}); err != nil { + t.Fatal(err) + } + if err := repo.CreateTaskRepository(ctx, &models.TaskRepository{ID: "attachment", TaskID: "task-123", RepositoryID: "remote-options", BaseBranch: "main", Metadata: metadata}); err != nil { + t.Fatal(err) + } + _, err := svc.UpdateTask(ctx, "task-123", &UpdateTaskRequest{Repositories: []TaskRepositoryInput{{RepositoryID: "remote-options"}}}) + if err != nil { + t.Fatal(err) + } + rows, err := repo.ListTaskRepositories(ctx, "task-123") + if err != nil { + t.Fatal(err) + } + if len(rows) != 1 { + t.Fatalf("rows = %d", len(rows)) + } + options, err := models.GetRepositoryCheckoutOptions(rows[0].Metadata) + if err != nil || options == nil || options.SparseDirectories[0] != "app" { + t.Fatalf("options after update = %+v, err %v", options, err) + } +} + +func TestRepositoryCheckoutOptionsRejectLocalRepository(t *testing.T) { + svc, _, repo := createTestService(t) + setupTestTask(t, repo) + ctx := context.Background() + if err := repo.CreateRepository(ctx, &models.Repository{ID: "local-options", WorkspaceID: "ws-1", Name: "Local", SourceType: "local", LocalPath: t.TempDir(), DefaultBranch: "main"}); err != nil { + t.Fatal(err) + } + _, err := svc.resolveTaskRepositoryRows(ctx, "ws-1", []TaskRepositoryInput{{RepositoryID: "local-options", BaseBranch: "main", CheckoutOptions: &models.RepositoryCheckoutOptions{Version: 1, DownloadMode: models.DownloadOnDemand}}}) + if err == nil { + t.Fatal("remote checkout options accepted for a user-owned local repository") + } +} + +// @covers AC-TASKS-REMOTE-OPTIONS-003.2 +func TestRepositoryCheckoutOptionsRejectInvalid(t *testing.T) { + for _, options := range []string{ + `{"version":2,"download_mode":"standard"}`, + `{"version":1,"download_mode":"shallow"}`, + `{"version":1,"download_mode":"standard","sparse_directories":["../outside"]}`, + `{"version":1,"download_mode":"standard","sparse_directories":["/outside"]}`, + } { + t.Run(options, func(t *testing.T) { + var input TaskRepositoryInput + if err := json.Unmarshal([]byte(`{"checkout_options":`+options+`}`), &input); err != nil { + t.Fatal(err) + } + if _, err := buildTaskRepositoryMetadata(input); err == nil { + t.Fatal("invalid checkout options accepted") + } + }) + } +} + +func TestRepositoryCheckoutCapabilitiesRejectExecutorCredentials(t *testing.T) { + svc, _, repo := createTestService(t) + setupTestTask(t, repo) + executor := createTestExecutor(t, svc, "Checkout runner", models.ExecutorTypeWorktree) + profile, err := svc.CreateExecutorProfile(context.Background(), &CreateExecutorProfileRequest{ExecutorID: executor.ID, Name: "Default", McpPolicy: "allow"}) + if err != nil { + t.Fatal(err) + } + svc.SetRepositoryCheckoutCredentialPolicy(func(context.Context, string) (bool, error) { return false, nil }) + capabilities, err := svc.GetRepositoryCheckoutCapabilities(context.Background(), "ws-1", TaskRepositoryInput{RemoteURL: "https://github.com/acme/repo"}, profile.ID) + if err != nil { + t.Fatal(err) + } + if capabilities.OnDemand || capabilities.Sparse || capabilities.Reason != "credentials_unsupported" { + t.Fatalf("unexpected credential capability: %+v", capabilities) + } +} + +func TestRepositoryCheckoutOptionsRejectAmbiguousAttachment(t *testing.T) { + rows := []*models.TaskRepository{ + {RepositoryID: "repo", BaseBranch: "main"}, + {RepositoryID: "repo", BaseBranch: "release"}, + } + if _, err := matchingRepositoryCheckoutOptions(TaskRepositoryInput{RepositoryID: "repo"}, rows); err == nil { + t.Fatal("ambiguous replacement silently discards checkout policy") + } +} + +func TestRepositoryCheckoutOptionsPreservedWhenBranchChanges(t *testing.T) { + metadata := map[string]interface{}{} + if err := models.PutRepositoryCheckoutOptions(metadata, &models.RepositoryCheckoutOptions{Version: 1, DownloadMode: models.DownloadOnDemand}); err != nil { + t.Fatal(err) + } + got, err := matchingRepositoryCheckoutOptions(TaskRepositoryInput{RepositoryID: "repo", BaseBranch: "new"}, []*models.TaskRepository{{RepositoryID: "repo", BaseBranch: "old", Metadata: metadata}}) + if err != nil || got == nil || got.DownloadMode != models.DownloadOnDemand { + t.Fatalf("branch change lost options: %v %v", got, err) + } +} diff --git a/apps/backend/internal/task/service/repository_selection.go b/apps/backend/internal/task/service/repository_selection.go index 7b2fb2c8bd..e46ad6d2d2 100644 --- a/apps/backend/internal/task/service/repository_selection.go +++ b/apps/backend/internal/task/service/repository_selection.go @@ -126,6 +126,7 @@ func trustResolvedRepositorySelection( // Branches and policy selectors belong to the task request. The resolver // may only replace provider identity and the exact clone URL. resolved.RepositoryID = "" + resolved.CheckoutOptions = requested.CheckoutOptions resolved.BaseBranch = requested.BaseBranch resolved.CheckoutBranch = requested.CheckoutBranch resolved.BranchPolicyID = requested.BranchPolicyID diff --git a/apps/backend/internal/task/service/service.go b/apps/backend/internal/task/service/service.go index 95468d93da..cedc289011 100644 --- a/apps/backend/internal/task/service/service.go +++ b/apps/backend/internal/task/service/service.go @@ -467,6 +467,7 @@ type Service struct { envDestroyer EnvironmentDestroyer wsGroupMembership WorkspaceGroupMembershipReader executorCapabilityProber ExecutorCapabilityProber + checkoutCredentialPolicy func(context.Context, string) (bool, error) sshTaskDirReclaimer SSHTaskDirReclaimer // orphanReapHostSnapshotter and orphanReapVerifier back the reap phase's // host process detection. Nil selects the real platform implementation diff --git a/apps/backend/internal/task/service/service_requests.go b/apps/backend/internal/task/service/service_requests.go index d7dcd1e814..a601afc44b 100644 --- a/apps/backend/internal/task/service/service_requests.go +++ b/apps/backend/internal/task/service/service_requests.go @@ -14,22 +14,23 @@ import ( // TaskRepositoryInput for creating/updating task repositories type TaskRepositoryInput struct { - RepositoryID string `json:"repository_id"` - BaseBranch string `json:"base_branch"` - CheckoutBranch string `json:"checkout_branch,omitempty"` - BranchPolicyID string `json:"branch_policy_id,omitempty"` - PRNumber int `json:"pr_number,omitempty"` // GitHub PR number when CheckoutBranch is a PR head; persisted into task_repositories.metadata["pr_number"]. - LocalPath string `json:"local_path,omitempty"` - Name string `json:"name,omitempty"` - DefaultBranch string `json:"default_branch,omitempty"` - GitHubURL string `json:"github_url,omitempty"` - RemoteURL string `json:"remote_url,omitempty"` - Provider string `json:"provider,omitempty"` - ProviderHost string `json:"provider_host,omitempty"` - ProviderScope string `json:"provider_scope,omitempty"` - ProviderRepoID string `json:"provider_repo_id,omitempty"` - ProviderOwner string `json:"provider_owner,omitempty"` - ProviderName string `json:"provider_name,omitempty"` + CheckoutOptions *models.RepositoryCheckoutOptions `json:"checkout_options,omitempty"` + RepositoryID string `json:"repository_id"` + BaseBranch string `json:"base_branch"` + CheckoutBranch string `json:"checkout_branch,omitempty"` + BranchPolicyID string `json:"branch_policy_id,omitempty"` + PRNumber int `json:"pr_number,omitempty"` // GitHub PR number when CheckoutBranch is a PR head; persisted into task_repositories.metadata["pr_number"]. + LocalPath string `json:"local_path,omitempty"` + Name string `json:"name,omitempty"` + DefaultBranch string `json:"default_branch,omitempty"` + GitHubURL string `json:"github_url,omitempty"` + RemoteURL string `json:"remote_url,omitempty"` + Provider string `json:"provider,omitempty"` + ProviderHost string `json:"provider_host,omitempty"` + ProviderScope string `json:"provider_scope,omitempty"` + ProviderRepoID string `json:"provider_repo_id,omitempty"` + ProviderOwner string `json:"provider_owner,omitempty"` + ProviderName string `json:"provider_name,omitempty"` // PreserveBaseBranch keeps an effective branch produced after policy // resolution (for example, the branch created by the local fresh-branch diff --git a/apps/backend/internal/task/service/service_tasks.go b/apps/backend/internal/task/service/service_tasks.go index 8dfd9dcca4..b6cbfbb599 100644 --- a/apps/backend/internal/task/service/service_tasks.go +++ b/apps/backend/internal/task/service/service_tasks.go @@ -303,6 +303,9 @@ func (s *Service) prepareTaskForCreation(ctx context.Context, req *CreateTaskReq if err := s.preflightRepositorySelections(ctx, req); err != nil { return nil, err } + if err := s.validateTaskCheckoutCapabilities(ctx, req); err != nil { + return nil, err + } if err := s.validateTaskRepositoryPolicies(ctx, req.WorkspaceID, req.Repositories); err != nil { return nil, err } @@ -732,9 +735,14 @@ func (s *Service) inheritParentRepositories(ctx context.Context, req *CreateTask if r == nil || r.RepositoryID == "" { continue } + options, err := models.GetRepositoryCheckoutOptions(r.Metadata) + if err != nil { + return err + } inherited = append(inherited, TaskRepositoryInput{ - RepositoryID: r.RepositoryID, - BaseBranch: r.BaseBranch, + CheckoutOptions: options, + RepositoryID: r.RepositoryID, + BaseBranch: r.BaseBranch, }) } if len(inherited) > 0 { @@ -1078,6 +1086,9 @@ func (s *Service) resolveTaskRepositoryRow( ctx context.Context, workspaceID string, index int, repoInput TaskRepositoryInput, repoByPath map[string]*models.Repository, ) (*models.TaskRepository, error) { + if err := s.validateRepositoryCheckoutInput(ctx, workspaceID, repoInput); err != nil { + return nil, err + } repoInput, err := normalizeContributionBindings(repoInput) if err != nil { return nil, err @@ -1171,6 +1182,9 @@ func applyBranchPolicyBaseBranch( // buildTaskRepositoryMetadata assembles the row's metadata blob. func buildTaskRepositoryMetadata(repoInput TaskRepositoryInput) (map[string]interface{}, error) { metadata := make(map[string]interface{}) + if err := models.PutRepositoryCheckoutOptions(metadata, repoInput.CheckoutOptions); err != nil { + return nil, err + } if prNum := resolvePRNumber(repoInput); prNum > 0 { metadata["pr_number"] = prNum } @@ -1968,6 +1982,9 @@ func (s *Service) UpdateTask(ctx context.Context, id string, req *UpdateTaskRequ return nil, err } if req.Repositories != nil { + if err := s.preserveRepositoryCheckoutOptions(ctx, task, req.Repositories); err != nil { + return nil, err + } if err := s.preflightRepositoryInputs(ctx, task.WorkspaceID, req.Repositories); err != nil { return nil, err } diff --git a/apps/backend/internal/worktree/checkout_options.go b/apps/backend/internal/worktree/checkout_options.go new file mode 100644 index 0000000000..b48175bc8a --- /dev/null +++ b/apps/backend/internal/worktree/checkout_options.go @@ -0,0 +1,79 @@ +package worktree + +import ( + "context" + "os" + "os/exec" + "sort" + "strings" + + "github.com/kandev/kandev/internal/gitcheckout" + "github.com/kandev/kandev/internal/task/models" +) + +type checkoutContextKey struct{} +type checkoutContext struct { + options *models.RepositoryCheckoutOptions + env []string +} + +func withCheckoutOptions(ctx context.Context, req CreateRequest) (context.Context, error) { + options, err := models.NormalizeRepositoryCheckoutOptions(req.CheckoutOptions) + if err != nil || options == nil { + return ctx, err + } + env := os.Environ() + keys := make([]string, 0, len(req.CheckoutEnv)) + for key := range req.CheckoutEnv { + keys = append(keys, key) + } + sort.Strings(keys) + for _, key := range keys { + env = append(env, key+"="+req.CheckoutEnv[key]) + } + return context.WithValue(ctx, checkoutContextKey{}, checkoutContext{options: options, env: env}), nil +} + +func scopedCheckout(ctx context.Context) checkoutContext { + scope, _ := ctx.Value(checkoutContextKey{}).(checkoutContext) + return scope +} + +func hasSparseCheckout(ctx context.Context) bool { + options := scopedCheckout(ctx).options + return options != nil && len(options.SparseDirectories) > 0 +} + +func configureCheckoutCommand(ctx context.Context, cmd *exec.Cmd) { + if scope := scopedCheckout(ctx); scope.options != nil { + cmd.Env = append([]string(nil), scope.env...) + } +} + +func applySparseCheckout(ctx context.Context, path string) error { + return gitcheckout.ApplySparse(ctx, path, scopedCheckout(ctx).options, func(ctx context.Context, args []string, input string) ([]byte, error) { + cmd := newGitCommand(ctx, args...) + cmd.Stdin = strings.NewReader(input) + return runGitCmdCombinedOutput(ctx, cmd) + }) +} + +func (m *Manager) finishWorktreeCheckout(ctx context.Context, repoPath, worktreePath string, usesGitCrypt bool) error { + var err error + if usesGitCrypt { + err = m.unlockGitCryptAndCheckout(ctx, worktreePath) + } else { + err = applySparseCheckout(ctx, worktreePath) + } + if err == nil { + err = gitcheckout.Save(worktreePath, scopedCheckout(ctx).options) + } + if err != nil { + _ = m.removeWorktreeDir(ctx, worktreePath, repoPath) + return err + } + if !usesGitCrypt { + m.initSubmodules(ctx, worktreePath) + } + return nil +} diff --git a/apps/backend/internal/worktree/git_command.go b/apps/backend/internal/worktree/git_command.go index 25eb9fc7ef..4bb0ddd0f1 100644 --- a/apps/backend/internal/worktree/git_command.go +++ b/apps/backend/internal/worktree/git_command.go @@ -15,5 +15,7 @@ func newGitCommand(ctx context.Context, args ...string) *exec.Cmd { commandArgs := make([]string, 0, len(args)) commandArgs = append(commandArgs, "-c", "core.longpaths=true") commandArgs = append(commandArgs, args...) - return subproc.NewGitCommand(ctx, commandArgs...) + cmd := subproc.NewGitCommand(ctx, commandArgs...) + configureCheckoutCommand(ctx, cmd) + return cmd } diff --git a/apps/backend/internal/worktree/gitcrypt.go b/apps/backend/internal/worktree/gitcrypt.go index eeed276122..948145e310 100644 --- a/apps/backend/internal/worktree/gitcrypt.go +++ b/apps/backend/internal/worktree/gitcrypt.go @@ -14,6 +14,10 @@ import ( // usesGitCrypt checks if a repository uses git-crypt by looking for // the git-crypt filter in .gitattributes files. func (m *Manager) usesGitCrypt(repoPath string) bool { + return m.usesGitCryptContext(context.Background(), repoPath) +} + +func (m *Manager) usesGitCryptContext(ctx context.Context, repoPath string) bool { // Check root .gitattributes if hasGitCryptFilter(filepath.Join(repoPath, ".gitattributes")) { return true @@ -27,7 +31,12 @@ func (m *Manager) usesGitCrypt(repoPath string) bool { } } - return false + // Managed caches have no working tree; read attributes through scoped Git. + inspectCtx, cancel := context.WithTimeout(ctx, m.inspectTimeout) + defer cancel() + cmd := m.newNonInteractiveGitCmd(inspectCtx, repoPath, "show", "HEAD:.gitattributes") + output, err := runGitCmdCombinedOutput(inspectCtx, cmd) + return err == nil && strings.Contains(string(output), "filter=git-crypt") } // hasGitCryptFilter checks if a gitattributes file contains the git-crypt filter. @@ -104,6 +113,14 @@ func (m *Manager) unlockGitCryptAndCheckout(ctx context.Context, worktreePath st } } + if hasSparseCheckout(ctx) { + if err := applySparseCheckout(ctx, worktreePath); err != nil { + return err + } + m.initSubmodules(ctx, worktreePath) + return nil + } + // Exclude submodule paths from checkout to avoid broken gitlink resolution. // In worktrees, git resolves submodule git dirs relative to the worktree's // git dir instead of the common dir, which fails. diff --git a/apps/backend/internal/worktree/manager_checkout_options_test.go b/apps/backend/internal/worktree/manager_checkout_options_test.go new file mode 100644 index 0000000000..33cb90571d --- /dev/null +++ b/apps/backend/internal/worktree/manager_checkout_options_test.go @@ -0,0 +1,177 @@ +package worktree + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +// @covers AC-TASKS-REMOTE-OPTIONS-003.1 +// @covers AC-TASKS-REMOTE-OPTIONS-003.5 +func TestRepositoryCheckoutOptionsIndependentWorktrees(t *testing.T) { + repository := initGitRepoForWorktreeTest(t) + for _, directory := range []string{"app", "other"} { + if err := os.Mkdir(filepath.Join(repository, directory), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(repository, directory, "file.txt"), []byte(directory), 0o600); err != nil { + t.Fatal(err) + } + } + runGit(t, repository, "add", ".") + runGit(t, repository, "-c", "commit.gpgsign=false", "commit", "-m", "directories") + manager, err := NewManager(newTestConfig(t), newMockStore(), newTestLogger()) + if err != nil { + t.Fatal(err) + } + paths := make(map[string]string) + for _, directory := range []string{"app", "other"} { + request := CreateRequest{TaskID: directory, SessionID: directory, RepositoryID: "repo", RepositoryPath: repository, BaseBranch: "main", TaskDirName: directory, RepoName: "repo"} + if err := json.Unmarshal([]byte(`{"checkout_options":{"version":1,"download_mode":"standard","sparse_directories":["`+directory+`"]}}`), &request); err != nil { + t.Fatal(err) + } + wt, err := manager.Create(context.Background(), request) + if err != nil { + t.Fatal(err) + } + paths[directory] = wt.Path + if value, err := os.ReadFile(filepath.Join(wt.Path, directory, "file.txt")); err != nil || string(value) != directory { + t.Fatalf("selected directory missing: %q, %v", value, err) + } + if output := runGit(t, wt.Path, "status", "--porcelain"); output != "" { + t.Fatalf("sparse files appear as changes: %q", output) + } + } + for selected, excluded := range map[string]string{"app": "other", "other": "app"} { + if _, err := os.Stat(filepath.Join(paths[selected], excluded, "file.txt")); !os.IsNotExist(err) { + t.Fatalf("task %s populated excluded folder %s", selected, excluded) + } + } + if _, err := os.Stat(filepath.Join(repository, "other", "file.txt")); err != nil { + t.Fatal("source checkout changed") + } +} + +func TestRepositoryCheckoutOptionsResumePreservesEdits(t *testing.T) { + repository := initGitRepoForWorktreeTest(t) + if err := os.Mkdir(filepath.Join(repository, "app"), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(repository, "app", "file.txt"), []byte("original"), 0600); err != nil { + t.Fatal(err) + } + runGit(t, repository, "add", ".") + runGit(t, repository, "-c", "commit.gpgsign=false", "commit", "-m", "app") + manager, err := NewManager(newTestConfig(t), newMockStore(), newTestLogger()) + if err != nil { + t.Fatal(err) + } + req := CreateRequest{TaskID: "task", SessionID: "session", RepositoryID: "repo", RepositoryPath: repository, BaseBranch: "main", TaskDirName: "task", RepoName: "repo"} + if err := json.Unmarshal([]byte(`{"checkout_options":{"version":1,"download_mode":"standard","sparse_directories":["app"]}}`), &req); err != nil { + t.Fatal(err) + } + wt, err := manager.Create(context.Background(), req) + if err != nil { + t.Fatal(err) + } + file := filepath.Join(wt.Path, "app", "file.txt") + if err := os.WriteFile(file, []byte("user edit"), 0600); err != nil { + t.Fatal(err) + } + req.CheckoutOptions.SparseDirectories = []string{"app", "app"} + if _, err := manager.Create(context.Background(), req); err != nil { + t.Fatal(err) + } + if data, err := os.ReadFile(file); err != nil || string(data) != "user edit" { + t.Fatalf("edit changed: %q %v", data, err) + } + req.CheckoutOptions.SparseDirectories = []string{"missing"} + if _, err := manager.Create(context.Background(), req); err == nil { + t.Fatal("changed scope silently reused a dirty worktree") + } + if data, err := os.ReadFile(file); err != nil || string(data) != "user edit" { + t.Fatalf("edit changed: %q %v", data, err) + } +} + +func TestRepositoryCheckoutOptionsSkipExcludedSubmodules(t *testing.T) { + repository := initGitRepoForWorktreeTest(t) + runGit(t, repository, "update-index", "--add", "--cacheinfo", "160000,"+strings.TrimSpace(runGit(t, repository, "rev-parse", "HEAD"))+",excluded/module") + runGit(t, repository, "-c", "commit.gpgsign=false", "commit", "-m", "submodule") + req := CreateRequest{} + if err := json.Unmarshal([]byte(`{"checkout_options":{"version":1,"download_mode":"standard","sparse_directories":["app"]}}`), &req); err != nil { + t.Fatal(err) + } + ctx, err := withCheckoutOptions(context.Background(), req) + if err != nil { + t.Fatal(err) + } + manager, err := NewManager(newTestConfig(t), newMockStore(), newTestLogger()) + if err != nil { + t.Fatal(err) + } + cmd, err := manager.scopedSubmoduleUpdateCmd(ctx, repository) + if err != nil { + t.Fatal(err) + } + if cmd != nil { + t.Fatal("excluded submodule would be initialized") + } +} + +func TestRepositoryCheckoutOptionsIgnoreScriptGitControls(t *testing.T) { + repository := initGitRepoForWorktreeTest(t) + req := CreateRequest{ScriptEnv: map[string]string{"GIT_DIR": "/missing/git", "GIT_WORK_TREE": "/missing/worktree", "GIT_CONFIG_COUNT": "1", "GIT_CONFIG_KEY_0": "core.bare", "GIT_CONFIG_VALUE_0": "true"}} + if err := json.Unmarshal([]byte(`{"checkout_options":{"version":1,"download_mode":"on_demand"}}`), &req); err != nil { + t.Fatal(err) + } + ctx, err := withCheckoutOptions(context.Background(), req) + if err != nil { + t.Fatal(err) + } + cmd := newGitCommand(ctx, "-C", repository, "rev-parse", "--is-bare-repository") + output, err := cmd.CombinedOutput() + if err != nil || strings.TrimSpace(string(output)) != "false" { + t.Fatalf("profile redirected checkout: %s %v", output, err) + } +} + +func TestRepositoryCheckoutOptionsAcceptExactSubmodule(t *testing.T) { + repository := initGitRepoForWorktreeTest(t) + runGit(t, repository, "update-index", "--add", "--cacheinfo", "160000,"+strings.TrimSpace(runGit(t, repository, "rev-parse", "HEAD"))+",module") + runGit(t, repository, "-c", "commit.gpgsign=false", "commit", "-m", "submodule") + req := CreateRequest{} + if err := json.Unmarshal([]byte(`{"checkout_options":{"version":1,"download_mode":"standard","sparse_directories":["module"]}}`), &req); err != nil { + t.Fatal(err) + } + ctx, err := withCheckoutOptions(context.Background(), req) + if err != nil { + t.Fatal(err) + } + if err := applySparseCheckout(ctx, repository); err != nil { + t.Fatal(err) + } +} + +func TestRepositoryCheckoutOptionsDetectsUnpopulatedGitCrypt(t *testing.T) { + repository := initGitRepoForWorktreeTest(t) + attributes := filepath.Join(repository, ".gitattributes") + if err := os.WriteFile(attributes, []byte("secret filter=git-crypt\n"), 0600); err != nil { + t.Fatal(err) + } + runGit(t, repository, "add", ".gitattributes") + runGit(t, repository, "-c", "commit.gpgsign=false", "commit", "-m", "attributes") + if err := os.Remove(attributes); err != nil { + t.Fatal(err) + } + manager, err := NewManager(newTestConfig(t), newMockStore(), newTestLogger()) + if err != nil { + t.Fatal(err) + } + if !manager.usesGitCrypt(repository) { + t.Fatal("unpopulated cache lost git-crypt detection") + } +} diff --git a/apps/backend/internal/worktree/manager_git.go b/apps/backend/internal/worktree/manager_git.go index 923f2d3678..ae323701c1 100644 --- a/apps/backend/internal/worktree/manager_git.go +++ b/apps/backend/internal/worktree/manager_git.go @@ -413,7 +413,11 @@ func (m *Manager) currentBranch(ctx context.Context, repoPath string) string { func (m *Manager) newNonInteractiveGitCmd(ctx context.Context, repoPath string, args ...string) *exec.Cmd { cmd := newGitCommand(ctx, args...) cmd.Dir = repoPath - cmd.Env = subproc.PrepareGitEnvironment(os.Environ()) + env := cmd.Env + if env == nil { + env = os.Environ() + } + cmd.Env = subproc.PrepareGitEnvironment(env) // After the context cancels and the process is killed, child processes // (e.g. credential helpers) may still hold stdout/stderr pipes open. // WaitDelay bounds how long CombinedOutput waits for those pipes to close. diff --git a/apps/backend/internal/worktree/manager_lifecycle.go b/apps/backend/internal/worktree/manager_lifecycle.go index f376fffd1a..361565fe0b 100644 --- a/apps/backend/internal/worktree/manager_lifecycle.go +++ b/apps/backend/internal/worktree/manager_lifecycle.go @@ -19,6 +19,7 @@ import ( "go.uber.org/zap" "github.com/kandev/kandev/internal/gitbootstrap" + "github.com/kandev/kandev/internal/gitcheckout" "github.com/kandev/kandev/internal/repoclone" "github.com/kandev/kandev/internal/worktree/copyfiles" ) @@ -103,11 +104,21 @@ func classifyRemoteDefaultError(output string, runErr error) error { // then by WorktreeID if provided (for session resumption). // Only creates a new worktree if none exists for the session. func (m *Manager) Create(ctx context.Context, req CreateRequest) (*Worktree, error) { + var scopeErr error + ctx, scopeErr = withCheckoutOptions(ctx, req) + if scopeErr != nil { + return nil, scopeErr + } + req.CheckoutOptions = scopedCheckout(ctx).options if err := req.Validate(); err != nil { return nil, err } if req.ReuseRequired { - return m.reuseRequiredWorktree(ctx, req) + wt, err := m.reuseRequiredWorktree(ctx, req) + if err == nil { + err = gitcheckout.Check(wt.Path, req.CheckoutOptions) + } + return wt, err } // Reject invalid explicit slugs up-front. If either slug is non-empty but @@ -122,6 +133,9 @@ func (m *Manager) Create(ctx context.Context, req CreateRequest) (*Worktree, err if wt, handled, err := m.tryReuseExisting(ctx, req); handled { if err == nil && wt != nil { + if err = gitcheckout.Check(wt.Path, req.CheckoutOptions); err != nil { + return nil, err + } err = m.configureContributionDestination(ctx, req.RepositoryPath, wt.Path, wt.Branch, req.ContributionDestination) } return wt, err @@ -1387,14 +1401,14 @@ func (m *Manager) gitAddWorktreeExistingAtRef(ctx context.Context, repoPath, bra func (m *Manager) gitAddWorktreeExistingLocked(ctx context.Context, repoPath, branchName, worktreePath, startPoint string) (string, error) { worktreeID := uuid.New().String() - usesGitCrypt := m.usesGitCrypt(repoPath) + usesGitCrypt := m.usesGitCryptContext(ctx, repoPath) // Build worktree add command args := []string{"worktree", "add"} if startPoint != "" { args = append(args, "-B", branchName) } - if usesGitCrypt { + if usesGitCrypt || hasSparseCheckout(ctx) { args = append(args, "--no-checkout") } args = append(args, worktreePath) @@ -1408,13 +1422,8 @@ func (m *Manager) gitAddWorktreeExistingLocked(ctx context.Context, repoPath, br cmd.Dir = repoPath output, err := runGitCmdCombinedOutput(ctx, cmd) if err == nil { - if usesGitCrypt { - if unlockErr := m.unlockGitCryptAndCheckout(ctx, worktreePath); unlockErr != nil { - _ = m.removeWorktreeDir(ctx, worktreePath, repoPath) - return "", unlockErr - } - } else { - m.initSubmodules(ctx, worktreePath) + if err := m.finishWorktreeCheckout(ctx, repoPath, worktreePath, usesGitCrypt); err != nil { + return "", err } return worktreeID, nil } @@ -1452,7 +1461,7 @@ func (m *Manager) retryWorktreeExisting(ctx context.Context, repoPath, branchNam if startPoint != "" { args = append(args, "-B", branchName) } - if usesGitCrypt { + if usesGitCrypt || hasSparseCheckout(ctx) { args = append(args, "--no-checkout") } args = append(args, worktreePath) @@ -1475,13 +1484,8 @@ func (m *Manager) retryWorktreeExisting(ctx context.Context, repoPath, branchNam return "", ClassifyGitError(retryOutStr, retryErr) } - if usesGitCrypt { - if err := m.unlockGitCryptAndCheckout(ctx, worktreePath); err != nil { - _ = m.removeWorktreeDir(ctx, worktreePath, repoPath) - return "", err - } - } else { - m.initSubmodules(ctx, worktreePath) + if err := m.finishWorktreeCheckout(ctx, repoPath, worktreePath, usesGitCrypt); err != nil { + return "", err } m.logger.Info("recovered from stale worktree checkout", zap.String("branch", branchName)) @@ -1745,14 +1749,14 @@ func (m *Manager) gitAddWorktree(ctx context.Context, repoPath, branchName, work func (m *Manager) gitAddWorktreeLocked(ctx context.Context, repoPath, branchName, worktreePath, baseRef string) (string, error) { worktreeID := uuid.New().String() - usesGitCrypt := m.usesGitCrypt(repoPath) + usesGitCrypt := m.usesGitCryptContext(ctx, repoPath) addSnapshot, err := m.createNewBranchRef(ctx, repoPath, branchName, baseRef) if err != nil { return "", err } args := []string{"worktree", "add"} - if usesGitCrypt { + if usesGitCrypt || hasSparseCheckout(ctx) { args = append(args, "--no-checkout") } args = append(args, worktreePath, branchName) @@ -1776,13 +1780,9 @@ func (m *Manager) gitAddWorktreeLocked(ctx context.Context, repoPath, branchName } // If we used --no-checkout, we need to unlock git-crypt and checkout - if usesGitCrypt { - if err := m.unlockGitCryptAndCheckout(ctx, worktreePath); err != nil { - m.rollbackFailedNewBranchAdd(ctx, repoPath, branchName, worktreePath, addSnapshot) - return "", err - } - } else { - m.initSubmodules(ctx, worktreePath) + if err := m.finishWorktreeCheckout(ctx, repoPath, worktreePath, usesGitCrypt); err != nil { + m.rollbackFailedNewBranchAdd(ctx, repoPath, branchName, worktreePath, addSnapshot) + return "", err } return worktreeID, nil @@ -1836,12 +1836,12 @@ func (m *Manager) gitAddWorktreeForRecreate(ctx context.Context, repoPath, branc } func (m *Manager) gitAddWorktreeForRecreateLocked(ctx context.Context, repoPath, branch, worktreePath, startPoint string) (bool, error) { - usesGitCrypt := m.usesGitCrypt(repoPath) + usesGitCrypt := m.usesGitCryptContext(ctx, repoPath) args := []string{"worktree", "add"} if startPoint != "" { args = append(args, "-B", branch) } - if usesGitCrypt { + if usesGitCrypt || hasSparseCheckout(ctx) { args = append(args, "--no-checkout") } args = append(args, worktreePath) @@ -2175,14 +2175,8 @@ func (m *Manager) recreate(ctx context.Context, existing *Worktree, req CreateRe return nil, err } - // If using git-crypt, unlock and checkout - if usesGitCrypt { - if err := m.unlockGitCryptAndCheckout(ctx, worktreePath); err != nil { - _ = m.removeWorktreeDir(ctx, worktreePath, req.RepositoryPath) - return nil, err - } - } else { - m.initSubmodules(ctx, worktreePath) + if err := m.finishWorktreeCheckout(ctx, req.RepositoryPath, worktreePath, usesGitCrypt); err != nil { + return nil, err } if contributionRemote != "" { if err := m.setUpstreamIfExistsRemote(ctx, worktreePath, existing.Branch, contributionRemote, req.RemoteContribution.HeadBranch); err != nil { diff --git a/apps/backend/internal/worktree/submodule.go b/apps/backend/internal/worktree/submodule.go index 1a4b840236..677b4b30fb 100644 --- a/apps/backend/internal/worktree/submodule.go +++ b/apps/backend/internal/worktree/submodule.go @@ -96,7 +96,14 @@ func getSubmodulePaths(ctx context.Context, dir string) ([]string, error) { // Failures are non-fatal: submodule URLs may be unreachable (private repos, // missing credentials), but the worktree is still usable for non-submodule files. func (m *Manager) initSubmodules(ctx context.Context, dir string) { - cmd := m.newSubmoduleUpdateCmd(ctx, dir) + cmd, err := m.scopedSubmoduleUpdateCmd(ctx, dir) + if err != nil { + m.logger.Warn("failed to discover sparse-checkout submodules", zap.String("dir", dir), zap.Error(err)) + return + } + if cmd == nil { + return + } output, err := runGitCmdCombinedOutput(ctx, cmd) if err != nil { m.logger.Warn("git submodule update --init failed (non-fatal)", @@ -107,3 +114,29 @@ func (m *Manager) initSubmodules(ctx context.Context, dir string) { } m.logger.Debug("initialized submodules in worktree", zap.String("dir", dir)) } + +func (m *Manager) scopedSubmoduleUpdateCmd(ctx context.Context, dir string) (*exec.Cmd, error) { + cmd := m.newSubmoduleUpdateCmd(ctx, dir) + if !hasSparseCheckout(ctx) { + return cmd, nil + } + paths, err := getSubmodulePaths(ctx, dir) + if err != nil { + return nil, err + } + var selected []string + for _, path := range paths { + for _, scope := range scopedCheckout(ctx).options.SparseDirectories { + if path == scope || strings.HasPrefix(path, scope+"/") { + selected = append(selected, path) + break + } + } + } + if len(selected) == 0 { + return nil, nil + } + cmd.Args = append(cmd.Args, "--") + cmd.Args = append(cmd.Args, selected...) + return cmd, nil +} diff --git a/apps/backend/internal/worktree/worktree.go b/apps/backend/internal/worktree/worktree.go index 56695205e2..3dc7e0d343 100644 --- a/apps/backend/internal/worktree/worktree.go +++ b/apps/backend/internal/worktree/worktree.go @@ -161,6 +161,7 @@ type Worktree struct { // CreateRequest contains the parameters for creating a new worktree. type CreateRequest struct { + CheckoutOptions *models.RepositoryCheckoutOptions `json:"checkout_options,omitempty"` // TaskID is the unique task identifier (required). TaskID string @@ -320,6 +321,9 @@ type CreateRequest struct { // secrets stay out of the DB. ScriptEnv map[string]string + // CheckoutEnv contains only managed Git credentials and their scoped configuration. + CheckoutEnv map[string]string + // OnSyncProgress receives progress updates for pre-worktree branch sync. OnSyncProgress SyncProgressCallback diff --git a/apps/backend/pkg/api/v1/task.go b/apps/backend/pkg/api/v1/task.go index 930efa4078..76af7ec20a 100644 --- a/apps/backend/pkg/api/v1/task.go +++ b/apps/backend/pkg/api/v1/task.go @@ -126,16 +126,24 @@ const ( MessageTypeTodo MessageType = "todo" ) +// RepositoryCheckoutOptions configures one task attachment's materialization. +type RepositoryCheckoutOptions struct { + Version int `json:"version"` + DownloadMode string `json:"download_mode"` + SparseDirectories []string `json:"sparse_directories"` +} + // TaskRepository represents a repository associated with a task type TaskRepository struct { - ID string `json:"id"` - TaskID string `json:"task_id"` - RepositoryID string `json:"repository_id"` - BaseBranch string `json:"base_branch"` - Position int `json:"position"` - Metadata map[string]interface{} `json:"metadata,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + CheckoutOptions *RepositoryCheckoutOptions `json:"checkout_options,omitempty"` + ID string `json:"id"` + TaskID string `json:"task_id"` + RepositoryID string `json:"repository_id"` + BaseBranch string `json:"base_branch"` + Position int `json:"position"` + Metadata map[string]interface{} `json:"metadata,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } // TaskWorkspaceFolder represents a non-Git folder associated with a task. @@ -190,9 +198,10 @@ type Task struct { // TaskRepositoryInput for creating/updating task repositories type TaskRepositoryInput struct { - RepositoryID string `json:"repository_id" binding:"required"` - BaseBranch string `json:"base_branch" binding:"required"` - BranchPolicyID string `json:"branch_policy_id,omitempty"` + CheckoutOptions *RepositoryCheckoutOptions `json:"checkout_options,omitempty"` + RepositoryID string `json:"repository_id" binding:"required"` + BaseBranch string `json:"base_branch" binding:"required"` + BranchPolicyID string `json:"branch_policy_id,omitempty"` } // CreateTaskRequest for creating a new task diff --git a/apps/web/components/task-create-dialog-checkout-options.test.ts b/apps/web/components/task-create-dialog-checkout-options.test.ts new file mode 100644 index 0000000000..9fb5806e80 --- /dev/null +++ b/apps/web/components/task-create-dialog-checkout-options.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { buildRepositoriesPayload } from "./task-create-dialog-helpers"; +import { applyRemoteRepoPatch } from "./task-create-dialog-repositories-state"; +import { parseCheckoutDirectories } from "./task-create-dialog-checkout-options"; +import type { TaskRemoteRepoRow } from "./task-create-dialog-types"; + +const options = { + version: 1 as const, + download_mode: "on_demand" as const, + sparse_directories: ["extensions/my extension"], +}; +const row = { + key: "one", + url: "https://github.com/acme/repo", + branch: "main", + source: "paste", + checkoutOptions: options, +} as TaskRemoteRepoRow; +describe("task-only repository checkout options", () => { + it("keeps options in the task payload for only the selected row", () => { + const payload = buildRepositoriesPayload({ + useRemote: true, + remoteRepos: [ + row, + { ...row, key: "two", url: "https://github.com/acme/other", checkoutOptions: undefined }, + ], + repositories: [], + discoveredRepositories: [], + }); + expect(payload?.[0]).toHaveProperty("checkout_options", options); + expect(payload?.[1]).not.toHaveProperty("checkout_options"); + }); + it("preserves branch edits and clears repository replacement", () => { + expect(applyRemoteRepoPatch(row, { branch: "feature" }).checkoutOptions).toEqual(options); + expect( + applyRemoteRepoPatch(row, { url: "https://github.com/acme/new" }).checkoutOptions, + ).toBeUndefined(); + }); + it("deduplicates directories and rejects invalid paths", () => { + expect(parseCheckoutDirectories("app\n../invalid\n/absolute")).toHaveProperty( + "errorLines", + [2, 3], + ); + expect(parseCheckoutDirectories(" app\napp\nshared files ")).toEqual({ + directories: [" app", "app", "shared files "], + }); + for (const value of ["../secret", "/app", "app//child", "app/*", "app\\child"]) + expect(parseCheckoutDirectories(value)).toHaveProperty("error"); + }); +}); diff --git a/apps/web/components/task-create-dialog-checkout-options.ts b/apps/web/components/task-create-dialog-checkout-options.ts new file mode 100644 index 0000000000..e10c46a87d --- /dev/null +++ b/apps/web/components/task-create-dialog-checkout-options.ts @@ -0,0 +1,32 @@ +import type { RepositoryCheckoutOptions } from "@/lib/types/repository-checkout-options"; + +export function parseCheckoutDirectories(value: string): { + directories: string[]; + error?: true; + errorLines?: number[]; +} { + const lines = value.split(/\r?\n/u); + const directories = [...new Set(lines.filter(Boolean))]; + const errorLines = lines.flatMap((line, index) => + line && invalidDirectory(line) ? [index + 1] : [], + ); + const invalid = + errorLines.length > 0 || + directories.length > 64 || + new TextEncoder().encode(JSON.stringify(directories)).length > 16_000; + return invalid ? { directories, error: true, errorLines } : { directories }; +} + +function invalidDirectory(directory: string): boolean { + return ( + new TextEncoder().encode(directory).length > 4096 || + /[\\:*?[\]{}\u0000-\u001f\u007f]/u.test(directory) || + directory.split("/").some((part) => !part || part === "." || part === "..") + ); +} + +export function hasCustomCheckoutOptions(options?: RepositoryCheckoutOptions): boolean { + return ( + !!options && (options.download_mode !== "standard" || options.sparse_directories.length > 0) + ); +} diff --git a/apps/web/components/task-create-dialog-helpers.ts b/apps/web/components/task-create-dialog-helpers.ts index 7a6d3b0607..068678b206 100644 --- a/apps/web/components/task-create-dialog-helpers.ts +++ b/apps/web/components/task-create-dialog-helpers.ts @@ -434,8 +434,10 @@ function buildRemoteRepoPayloadRow( ): CreateTaskRepositoryPayload { const url = row.url.trim(); const metadata = remoteRepoPRMetadata(row, url, prInfoByUrl); - if (metadata) return buildRemoteRepoPRPayload(row, url, metadata); - return buildPlainRemoteRepoPayload(row, url); + const payload = metadata + ? buildRemoteRepoPRPayload(row, url, metadata) + : buildPlainRemoteRepoPayload(row, url); + return row.checkoutOptions ? { ...payload, checkout_options: row.checkoutOptions } : payload; } function remoteRepoPRMetadata( diff --git a/apps/web/components/task-create-dialog-remote-repo-chip.test.tsx b/apps/web/components/task-create-dialog-remote-repo-chip.test.tsx index b41cfebcf0..25005ed331 100644 --- a/apps/web/components/task-create-dialog-remote-repo-chip.test.tsx +++ b/apps/web/components/task-create-dialog-remote-repo-chip.test.tsx @@ -588,3 +588,14 @@ describe("RemoteRepoChip — trigger label", () => { expect(screen.getByTestId(TRIGGER_TID).textContent).toContain("github.com/foo/bar"); }); }); + +describe("RemoteRepoChip checkout gear visibility", () => { + it("does not offer checkout options before a repository is selected", () => { + renderRemoteRepoChip({ onOptionsChange: vi.fn() }); + expect(screen.queryByTestId("repository-options-trigger")).toBeNull(); + }); + it("offers checkout options for a selected repository", () => { + renderRemoteRepoChip({ row: row({ url: URL_ACME_SITE }), onOptionsChange: vi.fn() }); + expect(screen.getByTestId("repository-options-trigger")).toBeTruthy(); + }); +}); diff --git a/apps/web/components/task-create-dialog-remote-repo-chip.tsx b/apps/web/components/task-create-dialog-remote-repo-chip.tsx index 5f749d9605..e38cd94d59 100644 --- a/apps/web/components/task-create-dialog-remote-repo-chip.tsx +++ b/apps/web/components/task-create-dialog-remote-repo-chip.tsx @@ -1,6 +1,11 @@ "use client"; import { useEffect, useMemo, useRef, useState } from "react"; +import { + RepositoryOptions, + RepositoryOptionsSummary, +} from "./task-create-dialog-repository-options"; +import type { RepositoryCheckoutOptions } from "@/lib/types/repository-checkout-options"; import Link from "@/components/routing/app-link"; import { IconCheck, IconGitBranch, IconLink, IconX } from "@tabler/icons-react"; import { cn } from "@/lib/utils"; @@ -42,6 +47,9 @@ import { t } from "@/lib/i18n"; const TRUNCATE_THRESHOLD = 30; export type RemoteRepoChipProps = { + workspaceId?: string | null; + executorProfileId?: string; + onOptionsChange?: (options?: RepositoryCheckoutOptions) => void; row: TaskRemoteRepoRow; branches: Branch[]; branchesLoading: boolean; @@ -81,6 +89,9 @@ export type RemoteRepoChipProps = { * the parent loads via `branchesByUrl`. */ export function RemoteRepoChip({ + workspaceId, + executorProfileId, + onOptionsChange, row, branches, branchesLoading, @@ -96,11 +107,11 @@ export function RemoteRepoChip({ useRowBranchAutoSelect({ row, branches, prInfo, onBranchChange }); return (
@@ -117,8 +128,18 @@ export function RemoteRepoChip({ branchesLoading={branchesLoading} onBranchChange={onBranchChange} /> + {onOptionsChange && row.url.trim() && ( + + )} + {resolutionError && onRetry ? ( ) : null} @@ -243,7 +264,7 @@ function RemoteRepoPill({ type="button" data-testid="remote-repo-chip-trigger" className={cn( - "h-7 inline-flex items-center gap-1.5 rounded-md px-2.5 text-xs bg-transparent", + "col-span-3 h-7 min-w-0 inline-flex items-center gap-1.5 rounded-md px-2.5 text-xs bg-transparent md:col-span-1 [@media(pointer:coarse)]:h-11", "hover:bg-muted/60 cursor-pointer", !hasValue && "text-muted-foreground", )} @@ -631,7 +652,7 @@ function RemoveButton({ onRemove }: { onRemove: () => void }) { onClick={onRemove} aria-label={t("task:removeRepository")} data-testid="remote-chip-remove" - className="h-6 w-6 inline-flex items-center justify-center rounded text-muted-foreground hover:text-destructive hover:bg-muted/60 cursor-pointer" + className="h-6 w-6 shrink-0 inline-flex items-center justify-center rounded text-muted-foreground hover:text-destructive hover:bg-muted/60 cursor-pointer [@media(pointer:coarse)]:size-11" > diff --git a/apps/web/components/task-create-dialog-remote-repo-chips.tsx b/apps/web/components/task-create-dialog-remote-repo-chips.tsx index 55eaa074b4..f38d9ec69b 100644 --- a/apps/web/components/task-create-dialog-remote-repo-chips.tsx +++ b/apps/web/components/task-create-dialog-remote-repo-chips.tsx @@ -87,6 +87,9 @@ export function RemoteRepoChipsRow({ return ( onUpdateRow(row.key, { checkoutOptions })} row={row} branches={fs.branchesByUrl.branches(row.url)} branchesLoading={fs.branchesByUrl.loading(row.url)} diff --git a/apps/web/components/task-create-dialog-repositories-state.ts b/apps/web/components/task-create-dialog-repositories-state.ts index 89a303a715..4e7e5d70a8 100644 --- a/apps/web/components/task-create-dialog-repositories-state.ts +++ b/apps/web/components/task-create-dialog-repositories-state.ts @@ -99,7 +99,7 @@ export function useRemoteReposState() { }; } -function applyRemoteRepoPatch( +export function applyRemoteRepoPatch( row: TaskRemoteRepoRow, patch: Partial, ): TaskRemoteRepoRow { @@ -109,6 +109,7 @@ function applyRemoteRepoPatch( return { ...row, remoteUrl: undefined, + checkoutOptions: undefined, provider: undefined, providerHost: undefined, providerScope: undefined, diff --git a/apps/web/components/task-create-dialog-repository-options-fields.tsx b/apps/web/components/task-create-dialog-repository-options-fields.tsx new file mode 100644 index 0000000000..39392f61d8 --- /dev/null +++ b/apps/web/components/task-create-dialog-repository-options-fields.tsx @@ -0,0 +1,165 @@ +import { useId } from "react"; +import type { TFunction } from "i18next"; +import { useTranslation } from "react-i18next"; +import { Textarea } from "@kandev/ui/textarea"; +import { Button } from "@kandev/ui/button"; +import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@kandev/ui/select"; +import type { RepositoryCheckoutCapabilities } from "@/lib/types/repository-checkout-options"; + +type Props = { + onDemand: boolean; + setOnDemand: (value: boolean) => void; + selectedFolders: boolean; + setSelectedFolders: (value: boolean) => void; + directories: string; + setDirectories: (value: string) => void; + invalid: boolean; + errorLines?: number[]; + capabilities?: RepositoryCheckoutCapabilities; + failed?: boolean; + retry: () => void; +}; +export function RepositoryOptionsFields(props: Props) { + const { t } = useTranslation(); + const id = useId(); + const { capabilities } = props; + return ( +
+

{t("task:checkoutOptions.taskOnly")}

+
+ +

+ {t("task:checkoutOptions.downloadHelp")} +

+
+ + {props.selectedFolders && ( +
+ +