feat: add per-task remote repository checkout options (#3817)
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -78,6 +78,7 @@ type RepoPrepareSpec struct {
|
||||
CheckoutBranch string
|
||||
PRNumber int // GitHub PR number when CheckoutBranch is a PR head; enables refs/pull/<N>/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/<N>/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,
|
||||
|
||||
@@ -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) })
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)).
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+94
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -1115,6 +1115,7 @@ type RepoLaunchSpec struct {
|
||||
CheckoutBranch string
|
||||
PRNumber int // GitHub PR number when CheckoutBranch is a PR head; enables refs/pull/<N>/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/<N>/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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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/<N>/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/<N>/head fetch for fork PRs.
|
||||
RemoteContribution *models.RemoteContribution
|
||||
CheckoutOptions *models.RepositoryCheckoutOptions
|
||||
ContributionDestination *models.ContributionDestination
|
||||
ComparisonTarget *models.ComparisonTarget
|
||||
WorktreeID string
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
className="flex max-w-full flex-col items-start gap-1"
|
||||
className="flex min-w-0 max-w-full flex-col items-start gap-1"
|
||||
data-testid="remote-repo-chip-wrapper"
|
||||
>
|
||||
<span
|
||||
className="inline-flex max-w-full items-center rounded-md border border-input bg-input/20 dark:bg-input/30 pr-0.5"
|
||||
className="grid max-w-full grid-cols-[minmax(0,1fr)_auto_auto] items-center rounded-md border border-input bg-input/20 dark:bg-input/30 pr-0.5 md:inline-flex"
|
||||
data-testid="remote-repo-chip"
|
||||
data-remote-url={row.url}
|
||||
>
|
||||
@@ -117,8 +128,18 @@ export function RemoteRepoChip({
|
||||
branchesLoading={branchesLoading}
|
||||
onBranchChange={onBranchChange}
|
||||
/>
|
||||
{onOptionsChange && row.url.trim() && (
|
||||
<RepositoryOptions
|
||||
key={row.url}
|
||||
row={row}
|
||||
workspaceId={workspaceId}
|
||||
executorProfileId={executorProfileId}
|
||||
onChange={onOptionsChange}
|
||||
/>
|
||||
)}
|
||||
<RemoveButton onRemove={onRemove} />
|
||||
</span>
|
||||
<RepositoryOptionsSummary options={row.checkoutOptions} />
|
||||
{resolutionError && onRetry ? (
|
||||
<RemoteResolutionError error={resolutionError} onRetry={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"
|
||||
>
|
||||
<IconX className="h-3 w-3" />
|
||||
</button>
|
||||
|
||||
@@ -87,6 +87,9 @@ export function RemoteRepoChipsRow({
|
||||
return (
|
||||
<RemoteRepoChip
|
||||
key={row.key}
|
||||
workspaceId={workspaceId}
|
||||
executorProfileId={fs.executorProfileId}
|
||||
onOptionsChange={(checkoutOptions) => onUpdateRow(row.key, { checkoutOptions })}
|
||||
row={row}
|
||||
branches={fs.branchesByUrl.branches(row.url)}
|
||||
branchesLoading={fs.branchesByUrl.loading(row.url)}
|
||||
|
||||
@@ -99,7 +99,7 @@ export function useRemoteReposState() {
|
||||
};
|
||||
}
|
||||
|
||||
function applyRemoteRepoPatch(
|
||||
export function applyRemoteRepoPatch(
|
||||
row: TaskRemoteRepoRow,
|
||||
patch: Partial<TaskRemoteRepoRow>,
|
||||
): TaskRemoteRepoRow {
|
||||
@@ -109,6 +109,7 @@ function applyRemoteRepoPatch(
|
||||
return {
|
||||
...row,
|
||||
remoteUrl: undefined,
|
||||
checkoutOptions: undefined,
|
||||
provider: undefined,
|
||||
providerHost: undefined,
|
||||
providerScope: undefined,
|
||||
|
||||
@@ -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 (
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs text-muted-foreground">{t("task:checkoutOptions.taskOnly")}</p>
|
||||
<div className="space-y-2">
|
||||
<CheckoutChoice
|
||||
id={`${id}-download`}
|
||||
label={t("task:checkoutOptions.download")}
|
||||
selected={props.onDemand}
|
||||
onChange={props.setOnDemand}
|
||||
defaultLabel={t("task:checkoutOptions.standard")}
|
||||
selectedLabel={t("task:checkoutOptions.onDemand")}
|
||||
disabled={!capabilities?.on_demand}
|
||||
testId="repository-options-download"
|
||||
/>
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||
{t("task:checkoutOptions.downloadHelp")}
|
||||
</p>
|
||||
</div>
|
||||
<CheckoutChoice
|
||||
id={`${id}-folders`}
|
||||
label={t("task:checkoutOptions.folders")}
|
||||
selected={props.selectedFolders}
|
||||
onChange={props.setSelectedFolders}
|
||||
defaultLabel={t("task:checkoutOptions.allFolders")}
|
||||
selectedLabel={t("task:checkoutOptions.selectedFolders")}
|
||||
disabled={!capabilities?.sparse}
|
||||
testId="repository-options-folders"
|
||||
/>
|
||||
{props.selectedFolders && (
|
||||
<div className="space-y-2">
|
||||
<label htmlFor={`${id}-directories`} className="text-xs text-muted-foreground">
|
||||
{t("task:checkoutOptions.directories")}
|
||||
</label>
|
||||
<Textarea
|
||||
id={`${id}-directories`}
|
||||
value={props.directories}
|
||||
onChange={(event) => props.setDirectories(event.target.value)}
|
||||
rows={4}
|
||||
className="resize-none text-base md:text-xs"
|
||||
aria-invalid={props.invalid}
|
||||
aria-describedby={`${id}-help`}
|
||||
data-testid="repository-options-directories"
|
||||
/>
|
||||
<p id={`${id}-help`} className="text-xs text-muted-foreground">
|
||||
{t("task:checkoutOptions.foldersHelp")}
|
||||
</p>
|
||||
{props.invalid && (
|
||||
<p role="alert" className="text-xs text-destructive">
|
||||
{t("task:checkoutOptions.invalidDirectories")}
|
||||
{props.errorLines?.map((line) => (
|
||||
<span key={line} className="block">
|
||||
{t("task:checkoutOptions.invalidLine", { line })}
|
||||
</span>
|
||||
))}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{(!capabilities || capabilities.reason) && (
|
||||
<p role="status" className="text-xs text-muted-foreground">
|
||||
{checkoutSupportMessage(props, t)}
|
||||
</p>
|
||||
)}
|
||||
{props.failed && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-7 cursor-pointer [@media(pointer:coarse)]:h-11"
|
||||
onClick={props.retry}
|
||||
>
|
||||
{t("task:retry")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CheckoutChoice({
|
||||
id,
|
||||
label,
|
||||
selected,
|
||||
onChange,
|
||||
defaultLabel,
|
||||
selectedLabel,
|
||||
disabled,
|
||||
testId,
|
||||
}: {
|
||||
id: string;
|
||||
label: string;
|
||||
selected: boolean;
|
||||
onChange: (value: boolean) => void;
|
||||
defaultLabel: string;
|
||||
selectedLabel: string;
|
||||
disabled: boolean;
|
||||
testId: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<label htmlFor={id} className="text-xs text-muted-foreground">
|
||||
{label}
|
||||
</label>
|
||||
<Select
|
||||
value={selected ? "selected" : "default"}
|
||||
onValueChange={(value) => onChange(value === "selected")}
|
||||
>
|
||||
<SelectTrigger
|
||||
id={id}
|
||||
data-testid={testId}
|
||||
className="w-36 border-border/60 bg-muted/30 hover:bg-muted/60"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="default" className="[@media(pointer:coarse)]:min-h-11">
|
||||
{defaultLabel}
|
||||
</SelectItem>
|
||||
<SelectItem
|
||||
value="selected"
|
||||
disabled={disabled}
|
||||
className="[@media(pointer:coarse)]:min-h-11"
|
||||
>
|
||||
{selectedLabel}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function checkoutSupportMessage(props: Props, t: TFunction) {
|
||||
if (props.failed) return t("task:checkoutOptions.unavailable");
|
||||
if (!props.capabilities) return t("task:checkoutOptions.checking");
|
||||
if (props.capabilities.reason === "credentials_unsupported")
|
||||
return t("task:checkoutOptions.credentialsUnsupported");
|
||||
if (props.capabilities.reason === "executor_required")
|
||||
return t("task:checkoutOptions.executorRequired");
|
||||
if (props.capabilities.reason === "provider_unsupported")
|
||||
return t("task:checkoutOptions.providerUnsupported");
|
||||
return t("task:checkoutOptions.preparationUnsupported");
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
import { useRef, useState, type ReactNode, type RefObject } from "react";
|
||||
import type { TFunction } from "i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { IconSettings } from "@tabler/icons-react";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import { Popover, PopoverTrigger, PopoverContent } from "@kandev/ui/popover";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerTrigger,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
DrawerDescription,
|
||||
} from "@kandev/ui/drawer";
|
||||
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
|
||||
import { useTaskCreateDialogPopoverContainer } from "@/hooks/use-task-create-dialog-popover-container";
|
||||
import { useRepositoryCheckoutCapabilities } from "@/hooks/domains/workspace/use-repository-checkout-capabilities";
|
||||
import type { RepositoryCheckoutOptions } from "@/lib/types/repository-checkout-options";
|
||||
import type { TaskRemoteRepoRow } from "./task-create-dialog-types";
|
||||
import {
|
||||
hasCustomCheckoutOptions,
|
||||
parseCheckoutDirectories,
|
||||
} from "./task-create-dialog-checkout-options";
|
||||
import { RepositoryOptionsFields } from "./task-create-dialog-repository-options-fields";
|
||||
|
||||
type Props = {
|
||||
row: TaskRemoteRepoRow;
|
||||
workspaceId?: string | null;
|
||||
executorProfileId?: string;
|
||||
onChange: (options?: RepositoryCheckoutOptions) => void;
|
||||
};
|
||||
export function RepositoryOptions({ row, workspaceId, executorProfileId, onChange }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [onDemand, setOnDemand] = useState(false);
|
||||
const [selectedFolders, setSelectedFolders] = useState(false);
|
||||
const [directories, setDirectories] = useState("");
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
const state = useRepositoryCheckoutCapabilities(
|
||||
workspaceId,
|
||||
row.remoteUrl ?? row.url,
|
||||
row.provider,
|
||||
executorProfileId,
|
||||
{ enabled: open, attempt },
|
||||
);
|
||||
const parsed = parseCheckoutDirectories(directories);
|
||||
const invalid = selectedFolders && (!!parsed.error || parsed.directories.length === 0);
|
||||
const unsupported =
|
||||
(onDemand && !state?.capabilities?.on_demand) ||
|
||||
(selectedFolders && !state?.capabilities?.sparse);
|
||||
const changeOpen = (value: boolean) => {
|
||||
if (value) {
|
||||
setOnDemand(row.checkoutOptions?.download_mode === "on_demand");
|
||||
setSelectedFolders(!!row.checkoutOptions?.sparse_directories.length);
|
||||
setDirectories(row.checkoutOptions?.sparse_directories.join("\n") ?? "");
|
||||
}
|
||||
setOpen(value);
|
||||
};
|
||||
const apply = () => {
|
||||
if (invalid || unsupported) return;
|
||||
onChange(
|
||||
onDemand || selectedFolders
|
||||
? {
|
||||
version: 1,
|
||||
download_mode: onDemand ? "on_demand" : "standard",
|
||||
sparse_directories: selectedFolders ? parsed.directories : [],
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
setOpen(false);
|
||||
};
|
||||
const trigger = renderOptionsTrigger(row, triggerRef, t);
|
||||
const fields = (
|
||||
<RepositoryOptionsFields
|
||||
onDemand={onDemand}
|
||||
setOnDemand={setOnDemand}
|
||||
selectedFolders={selectedFolders}
|
||||
setSelectedFolders={setSelectedFolders}
|
||||
directories={directories}
|
||||
setDirectories={setDirectories}
|
||||
invalid={invalid}
|
||||
errorLines={parsed.errorLines}
|
||||
capabilities={state?.capabilities}
|
||||
failed={state?.failed}
|
||||
retry={() => setAttempt((value) => value + 1)}
|
||||
/>
|
||||
);
|
||||
const footer = (
|
||||
<RepositoryOptionsFooter
|
||||
reset={() => {
|
||||
setOnDemand(false);
|
||||
setSelectedFolders(false);
|
||||
setDirectories("");
|
||||
}}
|
||||
cancel={() => setOpen(false)}
|
||||
apply={apply}
|
||||
disabled={invalid || unsupported}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<RepositoryOptionsSurface
|
||||
open={open}
|
||||
changeOpen={changeOpen}
|
||||
trigger={trigger}
|
||||
fields={fields}
|
||||
footer={footer}
|
||||
row={row}
|
||||
triggerRef={triggerRef}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function RepositoryOptionsSurface({
|
||||
open,
|
||||
changeOpen,
|
||||
trigger,
|
||||
fields,
|
||||
footer,
|
||||
row,
|
||||
triggerRef,
|
||||
}: {
|
||||
open: boolean;
|
||||
changeOpen: (value: boolean) => void;
|
||||
trigger: ReactNode;
|
||||
fields: ReactNode;
|
||||
footer: ReactNode;
|
||||
row: TaskRemoteRepoRow;
|
||||
triggerRef: RefObject<HTMLButtonElement | null>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { isMobile } = useResponsiveBreakpoint();
|
||||
const container = useTaskCreateDialogPopoverContainer();
|
||||
const title = t("task:checkoutOptions.title");
|
||||
if (isMobile)
|
||||
return (
|
||||
<Drawer open={open} onOpenChange={changeOpen}>
|
||||
<DrawerTrigger asChild>{trigger}</DrawerTrigger>
|
||||
<DrawerContent
|
||||
className="max-h-[85dvh]"
|
||||
data-testid="repository-options-drawer"
|
||||
onCloseAutoFocus={(event) => {
|
||||
event.preventDefault();
|
||||
triggerRef.current?.focus();
|
||||
}}
|
||||
>
|
||||
<DrawerHeader className="shrink-0 text-left">
|
||||
<DrawerTitle>{title}</DrawerTitle>
|
||||
<DrawerDescription className="truncate">{row.fullName ?? row.url}</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-4 pb-4">{fields}</div>
|
||||
<div className="shrink-0 pb-[env(safe-area-inset-bottom)]">{footer}</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
return (
|
||||
<Popover open={open} onOpenChange={changeOpen}>
|
||||
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
|
||||
<PopoverContent
|
||||
portalContainer={container}
|
||||
align="end"
|
||||
className="w-[320px] max-w-[calc(100vw-2rem)] p-0"
|
||||
data-testid="repository-options-popover"
|
||||
>
|
||||
<div className="max-h-[60vh] overflow-y-auto p-3">
|
||||
<h3 className="mb-3 text-xs font-medium">{title}</h3>
|
||||
{fields}
|
||||
</div>
|
||||
{footer}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
export function RepositoryOptionsSummary({ options }: { options?: RepositoryCheckoutOptions }) {
|
||||
const { t } = useTranslation();
|
||||
if (!hasCustomCheckoutOptions(options)) return null;
|
||||
return (
|
||||
<span
|
||||
className="max-w-full text-xs text-muted-foreground"
|
||||
data-testid="repository-options-summary"
|
||||
>
|
||||
{options?.download_mode === "on_demand"
|
||||
? t("task:checkoutOptions.onDemand")
|
||||
: t("task:checkoutOptions.standard")}
|
||||
{" · "}
|
||||
{options?.sparse_directories.length
|
||||
? t("task:checkoutOptions.folderCount", { count: options.sparse_directories.length })
|
||||
: t("task:checkoutOptions.allFolders")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function RepositoryOptionsFooter({
|
||||
reset,
|
||||
cancel,
|
||||
apply,
|
||||
disabled,
|
||||
}: {
|
||||
reset: () => void;
|
||||
cancel: () => void;
|
||||
apply: () => void;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex shrink-0 items-center gap-2 border-t p-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="mr-auto h-7 cursor-pointer [@media(pointer:coarse)]:h-11"
|
||||
onClick={reset}
|
||||
>
|
||||
{t("task:checkoutOptions.reset")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-7 cursor-pointer [@media(pointer:coarse)]:h-11"
|
||||
onClick={cancel}
|
||||
data-testid="repository-options-cancel"
|
||||
>
|
||||
{t("task:checkoutOptions.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
className="h-7 cursor-pointer [@media(pointer:coarse)]:h-11"
|
||||
onClick={apply}
|
||||
disabled={disabled}
|
||||
data-testid="repository-options-apply"
|
||||
>
|
||||
{t("task:checkoutOptions.apply")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderOptionsTrigger(
|
||||
row: TaskRemoteRepoRow,
|
||||
triggerRef: RefObject<HTMLButtonElement | null>,
|
||||
t: TFunction,
|
||||
) {
|
||||
const title = t("task:checkoutOptions.title");
|
||||
return (
|
||||
<Button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="relative size-7 shrink-0 cursor-pointer p-0 [@media(pointer:coarse)]:size-11"
|
||||
aria-label={
|
||||
row.url
|
||||
? t("task:checkoutOptions.openFor", { repository: row.fullName ?? row.url })
|
||||
: t("task:checkoutOptions.repositoryRequired")
|
||||
}
|
||||
title={row.url ? title : t("task:checkoutOptions.repositoryRequired")}
|
||||
disabled={!row.url}
|
||||
data-testid="repository-options-trigger"
|
||||
>
|
||||
<IconSettings className="size-3.5" />
|
||||
{hasCustomCheckoutOptions(row.checkoutOptions) && (
|
||||
<span className="absolute right-1 top-1 size-1.5 rounded-full bg-primary" />
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { RepositoryCheckoutOptions } from "@/lib/types/repository-checkout-options";
|
||||
import type React from "react";
|
||||
import type { RefObject } from "react";
|
||||
import type {
|
||||
@@ -160,6 +161,7 @@ export type TaskRepositorySnapshot = {
|
||||
* single `url` field, with `source` only used for UI affordance.
|
||||
*/
|
||||
export type TaskRemoteRepoRow = {
|
||||
checkoutOptions?: RepositoryCheckoutOptions;
|
||||
key: string; // stable client-side React key
|
||||
url: string; // canonical https://… or paste-as-typed
|
||||
/** Exact credential-free clone URL returned by provider URL inspection. */
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
import { MobileKanbanPage } from "../../pages/mobile-kanban-page";
|
||||
|
||||
test("remote checkout settings use a phone drawer with reachable actions", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}, testInfo) => {
|
||||
await apiClient.mockGitHubReset();
|
||||
await apiClient.saveUserSettings({
|
||||
workspace_id: seedData.workspaceId,
|
||||
workflow_filter_id: seedData.workflowId,
|
||||
task_create_last_used: {
|
||||
repository_id: seedData.repositoryId,
|
||||
branch: "main",
|
||||
agent_profile_id: seedData.agentProfileId,
|
||||
executor_profile_id: seedData.worktreeExecutorProfileId,
|
||||
},
|
||||
});
|
||||
await apiClient.mockGitHubAddBranches("checkout-options", "repo", [{ name: "main" }]);
|
||||
const mobile = new MobileKanbanPage(testPage);
|
||||
await mobile.goto();
|
||||
await mobile.mobileFab.click();
|
||||
await testPage.getByTestId("source-mode-remote").click();
|
||||
await expect(testPage.getByTestId("repository-options-trigger")).toHaveCount(0);
|
||||
await testPage.getByTestId("remote-repo-chip-trigger").first().click();
|
||||
await testPage.getByTestId("remote-repo-input").fill("https://github.com/checkout-options/repo");
|
||||
await testPage.getByTestId("remote-repo-input").press("Enter");
|
||||
const trigger = testPage.getByTestId("repository-options-trigger");
|
||||
expect((await trigger.boundingBox())!.height).toBeGreaterThanOrEqual(44);
|
||||
await trigger.click();
|
||||
const drawer = testPage.getByTestId("repository-options-drawer");
|
||||
await expect(drawer).toBeVisible();
|
||||
expect(
|
||||
await testPage.evaluate(
|
||||
() => document.documentElement.scrollWidth <= document.documentElement.clientWidth,
|
||||
),
|
||||
).toBe(true);
|
||||
const viewport = testPage.viewportSize()!;
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const box = (await drawer.boundingBox())!;
|
||||
return (
|
||||
box.x >= 0 &&
|
||||
box.x + box.width <= viewport.width &&
|
||||
box.y >= 0 &&
|
||||
box.y + box.height <= viewport.height
|
||||
);
|
||||
})
|
||||
.toBe(true);
|
||||
await testPage.getByTestId("repository-options-download").click();
|
||||
await expect(testPage.getByRole("option", { name: "On demand", exact: true })).toBeEnabled();
|
||||
await testPage.getByRole("option", { name: "On demand", exact: true }).click();
|
||||
await expect(testPage.getByRole("listbox")).toHaveCount(0);
|
||||
await testPage.getByTestId("repository-options-folders").click();
|
||||
await testPage.getByRole("option", { name: "Selected folders", exact: true }).click();
|
||||
await expect(testPage.getByRole("listbox")).toHaveCount(0);
|
||||
await testPage.getByTestId("repository-options-directories").fill("extensions/one");
|
||||
await expect(testPage.getByTestId("repository-options-apply")).toBeEnabled();
|
||||
await testPage.screenshot({
|
||||
animations: "disabled",
|
||||
path: testInfo.outputPath("repository-options-mobile.png"),
|
||||
});
|
||||
const apply = testPage.getByTestId("repository-options-apply");
|
||||
expect((await apply.boundingBox())!.height).toBeGreaterThanOrEqual(44);
|
||||
await apply.click();
|
||||
await expect(drawer).not.toBeVisible();
|
||||
await expect(trigger).toBeFocused();
|
||||
await expect(testPage.getByTestId("repository-options-summary")).toContainText("1 folder");
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
import { KanbanPage } from "../../pages/kanban-page";
|
||||
|
||||
test("remote repository gear applies task-only settings and cancels draft edits", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}, testInfo) => {
|
||||
await apiClient.mockGitHubReset();
|
||||
await apiClient.saveUserSettings({
|
||||
workspace_id: seedData.workspaceId,
|
||||
workflow_filter_id: seedData.workflowId,
|
||||
task_create_last_used: {
|
||||
repository_id: seedData.repositoryId,
|
||||
branch: "main",
|
||||
agent_profile_id: seedData.agentProfileId,
|
||||
executor_profile_id: seedData.worktreeExecutorProfileId,
|
||||
},
|
||||
});
|
||||
await apiClient.mockGitHubAddBranches("checkout-options", "repo", [{ name: "main" }]);
|
||||
const kanban = new KanbanPage(testPage);
|
||||
await kanban.goto();
|
||||
await kanban.createTaskButton.first().click();
|
||||
await testPage.getByTestId("source-mode-remote").click();
|
||||
await expect(testPage.getByTestId("repository-options-trigger")).toHaveCount(0);
|
||||
await testPage.getByTestId("remote-repo-chip-trigger").first().click();
|
||||
await testPage.getByTestId("remote-repo-input").fill("https://github.com/checkout-options/repo");
|
||||
await testPage.getByTestId("remote-repo-input").press("Enter");
|
||||
await apiClient.mockGitHubAddBranches("checkout-options", "other", [{ name: "main" }]);
|
||||
await testPage.getByTestId("remote-add-row").click();
|
||||
await testPage.getByTestId("remote-repo-chip-trigger").last().click();
|
||||
await testPage.getByTestId("remote-repo-input").fill("https://github.com/checkout-options/other");
|
||||
await testPage.getByTestId("remote-repo-input").press("Enter");
|
||||
await testPage.getByTestId("repository-options-trigger").first().click();
|
||||
await expect(testPage.getByText("For this task only", { exact: true })).toBeVisible();
|
||||
await testPage.getByTestId("repository-options-download").click();
|
||||
await expect(testPage.getByRole("option", { name: "On demand", exact: true })).toBeEnabled();
|
||||
await testPage.getByRole("option", { name: "On demand", exact: true }).click();
|
||||
await expect(testPage.getByRole("listbox")).toHaveCount(0);
|
||||
await testPage.getByTestId("repository-options-folders").click();
|
||||
await testPage.getByRole("option", { name: "Selected folders", exact: true }).click();
|
||||
await expect(testPage.getByRole("listbox")).toHaveCount(0);
|
||||
await testPage
|
||||
.getByTestId("repository-options-directories")
|
||||
.fill("extensions/my-extension\npackages/shared");
|
||||
await expect(testPage.getByTestId("repository-options-apply")).toBeEnabled();
|
||||
await testPage.screenshot({
|
||||
animations: "disabled",
|
||||
path: testInfo.outputPath("repository-options-desktop.png"),
|
||||
});
|
||||
await testPage.getByTestId("repository-options-apply").click();
|
||||
await expect(testPage.getByTestId("repository-options-popover")).toHaveCount(0);
|
||||
await expect(testPage.getByTestId("repository-options-summary")).toContainText("2 folders");
|
||||
await testPage.getByTestId("repository-options-trigger").last().click();
|
||||
await expect(testPage.getByTestId("repository-options-download")).toHaveText("Standard");
|
||||
await expect(testPage.getByTestId("repository-options-folders")).toHaveText("All folders");
|
||||
await testPage.getByTestId("repository-options-cancel").click();
|
||||
await expect(testPage.getByTestId("repository-options-popover")).toHaveCount(0);
|
||||
|
||||
await testPage.getByTestId("repository-options-trigger").first().click();
|
||||
await testPage.getByTestId("repository-options-directories").fill("../invalid");
|
||||
await expect(testPage.getByTestId("repository-options-apply")).toBeDisabled();
|
||||
await testPage.getByTestId("repository-options-cancel").click();
|
||||
await expect(testPage.getByTestId("repository-options-popover")).toHaveCount(0);
|
||||
await testPage.getByTestId("repository-options-trigger").first().click();
|
||||
await expect(testPage.getByTestId("repository-options-directories")).toHaveValue(
|
||||
"extensions/my-extension\npackages/shared",
|
||||
);
|
||||
await testPage.getByTestId("repository-options-cancel").click();
|
||||
await expect(testPage.getByTestId("repository-options-popover")).toHaveCount(0);
|
||||
await testPage.getByTestId("repository-options-trigger").first().click();
|
||||
await testPage.getByRole("button", { name: "Reset", exact: true }).click();
|
||||
await expect(testPage.getByTestId("repository-options-download")).toHaveText("Standard");
|
||||
await expect(testPage.getByTestId("repository-options-folders")).toHaveText("All folders");
|
||||
await testPage.getByTestId("repository-options-cancel").click();
|
||||
await expect(testPage.getByTestId("repository-options-popover")).toHaveCount(0);
|
||||
await testPage.getByTestId("task-title-input").fill("Repository options task");
|
||||
await testPage.getByTestId("task-description-input").fill("Use selected extension folders");
|
||||
await testPage.getByTestId("submit-start-agent-chevron").click();
|
||||
await testPage.getByTestId("submit-create-without-agent").click();
|
||||
await expect(testPage.getByTestId("create-task-dialog")).not.toBeVisible();
|
||||
const tasks = await apiClient.listTasks(seedData.workspaceId);
|
||||
const created = tasks.tasks.find((task) => task.title === "Repository options task");
|
||||
expect(created).toBeDefined();
|
||||
const response = await apiClient.rawRequest("GET", `/api/v1/tasks/${created!.id}`);
|
||||
const task = await response.json();
|
||||
expect(task.repositories).toHaveLength(2);
|
||||
expect(task.repositories[1]).not.toHaveProperty("checkout_options");
|
||||
expect(task.repositories[0].checkout_options).toEqual({
|
||||
version: 1,
|
||||
download_mode: "on_demand",
|
||||
sparse_directories: ["extensions/my-extension", "packages/shared"],
|
||||
});
|
||||
await kanban.createTaskButton.first().click();
|
||||
await testPage.getByTestId("source-mode-remote").click();
|
||||
await expect(testPage.getByTestId("repository-options-trigger")).toHaveCount(0);
|
||||
await expect(testPage.getByTestId("repository-options-summary")).toHaveCount(0);
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { getRepositoryCheckoutCapabilities } from "@/lib/api/domains/repository-checkout-api";
|
||||
import { useRepositoryCheckoutCapabilities } from "./use-repository-checkout-capabilities";
|
||||
import type { RepositoryCheckoutCapabilities } from "@/lib/types/repository-checkout-options";
|
||||
|
||||
vi.mock("@/lib/api/domains/repository-checkout-api", () => ({
|
||||
getRepositoryCheckoutCapabilities: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("repository checkout capability changes", () => {
|
||||
it("discards the previous executor response after switching profiles", async () => {
|
||||
let resolveOld!: (value: RepositoryCheckoutCapabilities) => void;
|
||||
const request = vi.mocked(getRepositoryCheckoutCapabilities);
|
||||
request.mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
resolveOld = resolve;
|
||||
}),
|
||||
);
|
||||
request.mockResolvedValueOnce({
|
||||
on_demand: false,
|
||||
sparse: false,
|
||||
reason: "preparation_unsupported",
|
||||
});
|
||||
const { result, rerender } = renderHook(
|
||||
({ profile }) =>
|
||||
useRepositoryCheckoutCapabilities(
|
||||
"workspace",
|
||||
"https://github.com/acme/repo",
|
||||
"github",
|
||||
profile,
|
||||
{ enabled: true },
|
||||
),
|
||||
{ initialProps: { profile: "worktree" } },
|
||||
);
|
||||
rerender({ profile: "local" });
|
||||
await waitFor(() =>
|
||||
expect(result.current?.capabilities?.reason).toBe("preparation_unsupported"),
|
||||
);
|
||||
await act(async () => resolveOld({ on_demand: true, sparse: true }));
|
||||
expect(result.current?.capabilities?.on_demand).toBe(false);
|
||||
expect(request.mock.calls[0][4].aborted).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("retries a failed capability request without changing repository identity", async () => {
|
||||
const request = vi.mocked(getRepositoryCheckoutCapabilities);
|
||||
request.mockRejectedValueOnce(new Error("offline"));
|
||||
request.mockResolvedValueOnce({ on_demand: true, sparse: true });
|
||||
const { result, rerender } = renderHook(
|
||||
({ attempt }) =>
|
||||
useRepositoryCheckoutCapabilities(
|
||||
"workspace",
|
||||
"https://github.com/acme/repo",
|
||||
"github",
|
||||
"worktree",
|
||||
{ enabled: true, attempt },
|
||||
),
|
||||
{ initialProps: { attempt: 0 } },
|
||||
);
|
||||
await waitFor(() => expect(result.current?.failed).toBe(true));
|
||||
rerender({ attempt: 1 });
|
||||
await waitFor(() => expect(result.current?.capabilities?.sparse).toBe(true));
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { getRepositoryCheckoutCapabilities } from "@/lib/api/domains/repository-checkout-api";
|
||||
import type { RepositoryCheckoutCapabilities } from "@/lib/types/repository-checkout-options";
|
||||
|
||||
type CapabilityState = {
|
||||
key: string;
|
||||
capabilities?: RepositoryCheckoutCapabilities;
|
||||
failed?: boolean;
|
||||
};
|
||||
export function useRepositoryCheckoutCapabilities(
|
||||
workspaceId: string | null | undefined,
|
||||
remoteUrl: string,
|
||||
provider: string | undefined,
|
||||
executorProfileId: string | undefined,
|
||||
{ enabled, attempt = 0 }: { enabled: boolean; attempt?: number },
|
||||
) {
|
||||
const key = JSON.stringify([workspaceId, remoteUrl, provider, executorProfileId, attempt]);
|
||||
const [state, setState] = useState<CapabilityState>();
|
||||
useEffect(() => {
|
||||
if (!enabled || !workspaceId || !remoteUrl) return;
|
||||
const controller = new AbortController();
|
||||
getRepositoryCheckoutCapabilities(
|
||||
workspaceId,
|
||||
remoteUrl,
|
||||
provider,
|
||||
executorProfileId ?? "",
|
||||
controller.signal,
|
||||
).then(
|
||||
(capabilities) => {
|
||||
if (!controller.signal.aborted) setState({ key, capabilities });
|
||||
},
|
||||
() => {
|
||||
if (!controller.signal.aborted) setState({ key, failed: true });
|
||||
},
|
||||
);
|
||||
return () => controller.abort();
|
||||
}, [enabled, workspaceId, remoteUrl, provider, executorProfileId, key]);
|
||||
return state?.key === key ? state : undefined;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { RepositoryCheckoutOptions } from "@/lib/types/repository-checkout-options";
|
||||
import { fetchJson, type ApiRequestOptions } from "../client";
|
||||
import { getBackendConfig } from "@/lib/config";
|
||||
import type {
|
||||
@@ -68,6 +69,7 @@ export async function createTask(
|
||||
workflow_step_id?: string;
|
||||
position?: number;
|
||||
repositories?: Array<{
|
||||
checkout_options?: RepositoryCheckoutOptions;
|
||||
repository_id: string;
|
||||
branch_policy_id?: string;
|
||||
base_branch?: string;
|
||||
@@ -140,6 +142,7 @@ export async function updateTask(
|
||||
position?: number;
|
||||
state?: Task["state"];
|
||||
repositories?: Array<{
|
||||
checkout_options?: RepositoryCheckoutOptions;
|
||||
repository_id: string;
|
||||
base_branch?: string;
|
||||
}>;
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { fetchJson } from "../client";
|
||||
import type { RepositoryCheckoutCapabilities } from "@/lib/types/repository-checkout-options";
|
||||
|
||||
export function getRepositoryCheckoutCapabilities(
|
||||
workspaceId: string,
|
||||
remoteUrl: string,
|
||||
provider: string | undefined,
|
||||
executorProfileId: string,
|
||||
signal: AbortSignal,
|
||||
) {
|
||||
return fetchJson<RepositoryCheckoutCapabilities>(
|
||||
`/api/v1/workspaces/${encodeURIComponent(workspaceId)}/repository-checkout-capabilities`,
|
||||
{
|
||||
init: {
|
||||
method: "POST",
|
||||
signal,
|
||||
body: JSON.stringify({
|
||||
repository: { remote_url: remoteUrl, provider },
|
||||
executor_profile_id: executorProfileId,
|
||||
}),
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { RepositoryCheckoutOptions } from "./repository-checkout-options";
|
||||
import type { RepositoryId, SessionId, TaskId } from "./ids";
|
||||
|
||||
export type RepositoryBranchPolicy = {
|
||||
@@ -13,6 +14,7 @@ export type RepositoryBranchPolicy = {
|
||||
};
|
||||
|
||||
export type TaskRepository = {
|
||||
checkout_options?: RepositoryCheckoutOptions;
|
||||
id: string;
|
||||
task_id: TaskId;
|
||||
repository_id: RepositoryId;
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
export type RepositoryCheckoutOptions = {
|
||||
version: 1;
|
||||
download_mode: "standard" | "on_demand";
|
||||
sparse_directories: string[];
|
||||
};
|
||||
export type RepositoryCheckoutCapabilities = {
|
||||
on_demand: boolean;
|
||||
sparse: boolean;
|
||||
reason?:
|
||||
| "provider_unsupported"
|
||||
| "executor_required"
|
||||
| "preparation_unsupported"
|
||||
| "credentials_unsupported";
|
||||
};
|
||||
@@ -22,7 +22,8 @@
|
||||
"task": {
|
||||
"planCommentMigrationPending": "仍在恢復已儲存的計劃評論。您的訊息會保留。",
|
||||
"proxy": "代理伺服器",
|
||||
"pathBasedProxyWorksForApis": "路徑型代理伺服器。適用於 API,但可能導致預期由 / 提供服務的 Web 應用程式無法正常運作。"
|
||||
"pathBasedProxyWorksForApis": "路徑型代理伺服器。適用於 API,但可能導致預期由 / 提供服務的 Web 應用程式無法正常運作。",
|
||||
"checkoutOptions.providerUnsupported": "高階檢出選項目前適用於 GitHub 儲存庫。"
|
||||
},
|
||||
"workspaces": {
|
||||
"failedToSaveRepository": "無法儲存此儲存庫"
|
||||
|
||||
@@ -2279,6 +2279,32 @@
|
||||
"planCommentMigrationFailed": "Some saved plan comments could not be restored.",
|
||||
"planCommentMigrationNeedsPlan": "Create or restore a plan to recover saved comments.",
|
||||
"planCommentMigrationPending": "Saved plan comments are still being restored. Your message is kept.",
|
||||
"checkoutOptions.title": "Repository options",
|
||||
"checkoutOptions.taskOnly": "For this task only",
|
||||
"checkoutOptions.download": "Download",
|
||||
"checkoutOptions.standard": "Standard",
|
||||
"checkoutOptions.onDemand": "On demand",
|
||||
"checkoutOptions.downloadHelp": "On demand downloads file contents when needed and keeps full commit history.",
|
||||
"checkoutOptions.folders": "Checkout folders",
|
||||
"checkoutOptions.allFolders": "All folders",
|
||||
"checkoutOptions.selectedFolders": "Selected folders",
|
||||
"checkoutOptions.directories": "Directories",
|
||||
"checkoutOptions.foldersHelp": "One repository-relative directory per line. Root files and parent-directory files stay available. Up to 64 directories.",
|
||||
"checkoutOptions.invalidDirectories": "Enter valid relative directories, without wildcards or parent paths.",
|
||||
"checkoutOptions.unavailable": "Could not check support.",
|
||||
"checkoutOptions.checking": "Checking repository support…",
|
||||
"checkoutOptions.executorRequired": "Choose an executor to check support.",
|
||||
"checkoutOptions.providerUnsupported": "Advanced checkout options are currently available for GitHub repositories.",
|
||||
"checkoutOptions.preparationUnsupported": "Choose Worktree or Local Docker with the built-in preparation script to use these options.",
|
||||
"checkoutOptions.reset": "Reset",
|
||||
"checkoutOptions.cancel": "Cancel",
|
||||
"checkoutOptions.apply": "Apply",
|
||||
"checkoutOptions.folderCount_one": "{{count}} folder",
|
||||
"checkoutOptions.folderCount_other": "{{count}} folders",
|
||||
"checkoutOptions.openFor": "Repository options for {{repository}}",
|
||||
"checkoutOptions.repositoryRequired": "Choose a repository first",
|
||||
"checkoutOptions.invalidLine": "Invalid directory on line {{line}}.",
|
||||
"checkoutOptions.credentialsUnsupported": "Use Kandev-managed Git credentials for advanced checkout options.",
|
||||
"toolPayloadRemovedAt": "Tool details removed on {{date}}.",
|
||||
"toolPayloadRemoved": "Tool details removed."
|
||||
}
|
||||
|
||||
@@ -2279,6 +2279,32 @@
|
||||
"planCommentMigrationFailed": "Śōḿē śàvēď ƥĺàń ćōḿḿēńţś ćōũĺď ńōţ ƀē ŕēśţōŕēď.",
|
||||
"planCommentMigrationNeedsPlan": "Ćŕēàţē ōŕ ŕēśţōŕē à ƥĺàń ţō ŕēćōvēŕ śàvēď ćōḿḿēńţś.",
|
||||
"planCommentMigrationPending": "Śàvēď ƥĺàń ćōḿḿēńţś àŕē śţĩĺĺ ƀēĩńĝ ŕēśţōŕēď. Ŷōũŕ ḿēśśàĝē ĩś ķēƥţ.",
|
||||
"checkoutOptions.title": "Ŕēƥōśĩţōŕŷ ōƥţĩōńś",
|
||||
"checkoutOptions.taskOnly": "Ƒōŕ ţĥĩś ţàśķ ōńĺŷ",
|
||||
"checkoutOptions.download": "Ďōŵńĺōàď",
|
||||
"checkoutOptions.standard": "Śţàńďàŕď",
|
||||
"checkoutOptions.onDemand": "Ōń ďēḿàńď",
|
||||
"checkoutOptions.downloadHelp": "Ōń ďēḿàńď ďōŵńĺōàďś ƒĩĺē ćōńţēńţś ŵĥēń ńēēďēď àńď ķēēƥś ƒũĺĺ ćōḿḿĩţ ĥĩśţōŕŷ.",
|
||||
"checkoutOptions.folders": "Ćĥēćķōũţ ƒōĺďēŕś",
|
||||
"checkoutOptions.allFolders": "Àĺĺ ƒōĺďēŕś",
|
||||
"checkoutOptions.selectedFolders": "Śēĺēćţēď ƒōĺďēŕś",
|
||||
"checkoutOptions.directories": "Ďĩŕēćţōŕĩēś",
|
||||
"checkoutOptions.foldersHelp": "Ōńē ŕēƥōśĩţōŕŷ-ŕēĺàţĩvē ďĩŕēćţōŕŷ ƥēŕ ĺĩńē. Ŕōōţ ƒĩĺēś àńď ƥàŕēńţ-ďĩŕēćţōŕŷ ƒĩĺēś śţàŷ àvàĩĺàƀĺē. Ũƥ ţō 64 ďĩŕēćţōŕĩēś.",
|
||||
"checkoutOptions.invalidDirectories": "Ēńţēŕ vàĺĩď ŕēĺàţĩvē ďĩŕēćţōŕĩēś, ŵĩţĥōũţ ŵĩĺďćàŕďś ōŕ ƥàŕēńţ ƥàţĥś.",
|
||||
"checkoutOptions.unavailable": "Ćōũĺď ńōţ ćĥēćķ śũƥƥōŕţ.",
|
||||
"checkoutOptions.checking": "Ćĥēćķĩńĝ ŕēƥōśĩţōŕŷ śũƥƥōŕţ…",
|
||||
"checkoutOptions.executorRequired": "Ćĥōōśē àń ēxēćũţōŕ ţō ćĥēćķ śũƥƥōŕţ.",
|
||||
"checkoutOptions.providerUnsupported": "Àďvàńćēď ćĥēćķōũţ ōƥţĩōńś àŕē ćũŕŕēńţĺŷ àvàĩĺàƀĺē ƒōŕ ĜĩţĤũƀ ŕēƥōśĩţōŕĩēś.",
|
||||
"checkoutOptions.preparationUnsupported": "Ćĥōōśē Ŵōŕķţŕēē ōŕ Ĺōćàĺ Ďōćķēŕ ŵĩţĥ ţĥē ƀũĩĺţ-ĩń ƥŕēƥàŕàţĩōń śćŕĩƥţ ţō ũśē ţĥēśē ōƥţĩōńś.",
|
||||
"checkoutOptions.reset": "Ŕēśēţ",
|
||||
"checkoutOptions.cancel": "Ćàńćēĺ",
|
||||
"checkoutOptions.apply": "Àƥƥĺŷ",
|
||||
"checkoutOptions.folderCount_one": "{{count}} ƒōĺďēŕ",
|
||||
"checkoutOptions.folderCount_other": "{{count}} ƒōĺďēŕś",
|
||||
"checkoutOptions.openFor": "Ŕēƥōśĩţōŕŷ ōƥţĩōńś ƒōŕ {{repository}}",
|
||||
"checkoutOptions.repositoryRequired": "Ćĥōōśē à ŕēƥōśĩţōŕŷ ƒĩŕśţ",
|
||||
"checkoutOptions.invalidLine": "Ĩńvàĺĩď ďĩŕēćţōŕŷ ōń ĺĩńē {{line}}.",
|
||||
"checkoutOptions.credentialsUnsupported": "Ũśē Ķàńďēv-ḿàńàĝēď Ĝĩţ ćŕēďēńţĩàĺś ƒōŕ àďvàńćēď ćĥēćķōũţ ōƥţĩōńś.",
|
||||
"toolPayloadRemovedAt": "Ţōōĺ ďēţàĩĺś ŕēḿōvēď ōń {{date}}.",
|
||||
"toolPayloadRemoved": "Ţōōĺ ďēţàĩĺś ŕēḿōvēď."
|
||||
}
|
||||
|
||||
@@ -2279,6 +2279,32 @@
|
||||
"planCommentMigrationFailed": "Não foi possível restaurar alguns comentários do plano guardados.",
|
||||
"planCommentMigrationNeedsPlan": "Crie ou restaure um plano para recuperar os comentários guardados.",
|
||||
"planCommentMigrationPending": "Os comentários do plano guardados ainda estão a ser restaurados. A sua mensagem é mantida.",
|
||||
"checkoutOptions.title": "Opções do repositório",
|
||||
"checkoutOptions.taskOnly": "Apenas para esta tarefa",
|
||||
"checkoutOptions.download": "Transferência",
|
||||
"checkoutOptions.standard": "Normal",
|
||||
"checkoutOptions.onDemand": "A pedido",
|
||||
"checkoutOptions.downloadHelp": "O modo a pedido transfere o conteúdo dos ficheiros quando necessário e mantém todo o histórico de commits.",
|
||||
"checkoutOptions.folders": "Pastas a obter",
|
||||
"checkoutOptions.allFolders": "Todas as pastas",
|
||||
"checkoutOptions.selectedFolders": "Pastas selecionadas",
|
||||
"checkoutOptions.directories": "Diretórios",
|
||||
"checkoutOptions.foldersHelp": "Um diretório relativo ao repositório por linha. Os ficheiros da raiz e das pastas superiores continuam disponíveis. Até 64 diretórios.",
|
||||
"checkoutOptions.invalidDirectories": "Introduza diretórios relativos válidos, sem carateres universais nem caminhos para pastas superiores.",
|
||||
"checkoutOptions.unavailable": "Não foi possível verificar o suporte.",
|
||||
"checkoutOptions.checking": "A verificar o suporte do repositório…",
|
||||
"checkoutOptions.executorRequired": "Escolha um executor para verificar o suporte.",
|
||||
"checkoutOptions.providerUnsupported": "As opções avançadas estão atualmente disponíveis para repositórios do GitHub.",
|
||||
"checkoutOptions.preparationUnsupported": "Escolha Worktree ou Docker local com o script de preparação integrado para utilizar estas opções.",
|
||||
"checkoutOptions.reset": "Repor",
|
||||
"checkoutOptions.cancel": "Cancelar",
|
||||
"checkoutOptions.apply": "Aplicar",
|
||||
"checkoutOptions.folderCount_one": "{{count}} pasta",
|
||||
"checkoutOptions.folderCount_other": "{{count}} pastas",
|
||||
"checkoutOptions.openFor": "Opções do repositório {{repository}}",
|
||||
"checkoutOptions.repositoryRequired": "Escolha primeiro um repositório",
|
||||
"checkoutOptions.invalidLine": "Diretório inválido na linha {{line}}.",
|
||||
"checkoutOptions.credentialsUnsupported": "Utilize credenciais Git geridas pelo Kandev para as opções avançadas de obtenção.",
|
||||
"toolPayloadRemovedAt": "Detalhes da ferramenta removidos em {{date}}.",
|
||||
"toolPayloadRemoved": "Detalhes da ferramenta removidos."
|
||||
}
|
||||
|
||||
@@ -2279,6 +2279,32 @@
|
||||
"planCommentMigrationFailed": "无法恢复部分已保存的计划评论。",
|
||||
"planCommentMigrationNeedsPlan": "请创建或恢复计划,以恢复已保存的评论。",
|
||||
"planCommentMigrationPending": "仍在恢复已保存的计划评论。您的消息会保留。",
|
||||
"checkoutOptions.title": "仓库选项",
|
||||
"checkoutOptions.taskOnly": "仅用于此任务",
|
||||
"checkoutOptions.download": "下载方式",
|
||||
"checkoutOptions.standard": "标准",
|
||||
"checkoutOptions.onDemand": "按需",
|
||||
"checkoutOptions.downloadHelp": "按需下载仅在需要时获取文件内容,并保留完整的提交历史。",
|
||||
"checkoutOptions.folders": "检出文件夹",
|
||||
"checkoutOptions.allFolders": "所有文件夹",
|
||||
"checkoutOptions.selectedFolders": "选定的文件夹",
|
||||
"checkoutOptions.directories": "目录",
|
||||
"checkoutOptions.foldersHelp": "每行输入一个相对于仓库的目录。根目录及父目录中的文件仍可使用。最多 64 个目录。",
|
||||
"checkoutOptions.invalidDirectories": "请输入有效的相对目录,不可包含通配符或上级路径。",
|
||||
"checkoutOptions.unavailable": "无法检查支持情况。",
|
||||
"checkoutOptions.checking": "正在检查仓库支持情况…",
|
||||
"checkoutOptions.executorRequired": "选择执行器以检查支持情况。",
|
||||
"checkoutOptions.providerUnsupported": "高级检出选项目前适用于 GitHub 仓库。",
|
||||
"checkoutOptions.preparationUnsupported": "请选择使用内置准备脚本的 Worktree 或本地 Docker 执行器。",
|
||||
"checkoutOptions.reset": "重置",
|
||||
"checkoutOptions.cancel": "取消",
|
||||
"checkoutOptions.apply": "应用",
|
||||
"checkoutOptions.folderCount_one": "{{count}} 个文件夹",
|
||||
"checkoutOptions.folderCount_other": "{{count}} 个文件夹",
|
||||
"checkoutOptions.openFor": "{{repository}} 的仓库选项",
|
||||
"checkoutOptions.repositoryRequired": "请先选择仓库",
|
||||
"checkoutOptions.invalidLine": "第 {{line}} 行的目录无效。",
|
||||
"checkoutOptions.credentialsUnsupported": "请使用由 Kandev 管理的 Git 凭据启用高级检出选项。",
|
||||
"toolPayloadRemovedAt": "工具详细数据已于 {{date}} 删除。",
|
||||
"toolPayloadRemoved": "工具详细数据已删除。"
|
||||
}
|
||||
|
||||
@@ -2279,6 +2279,32 @@
|
||||
"planCommentMigrationFailed": "無法恢復部分已儲存的計劃評論。",
|
||||
"planCommentMigrationNeedsPlan": "請建立或恢復計劃,以恢復已儲存的評論。",
|
||||
"planCommentMigrationPending": "仍在恢復已儲存的計劃評論。您的訊息會保留。",
|
||||
"checkoutOptions.title": "儲存庫選項",
|
||||
"checkoutOptions.taskOnly": "僅用於此任務",
|
||||
"checkoutOptions.download": "下載方式",
|
||||
"checkoutOptions.standard": "標準",
|
||||
"checkoutOptions.onDemand": "按需",
|
||||
"checkoutOptions.downloadHelp": "按需下載僅在需要時擷取檔案內容,並保留完整的提交歷史。",
|
||||
"checkoutOptions.folders": "檢出資料夾",
|
||||
"checkoutOptions.allFolders": "所有資料夾",
|
||||
"checkoutOptions.selectedFolders": "選定的資料夾",
|
||||
"checkoutOptions.directories": "目錄",
|
||||
"checkoutOptions.foldersHelp": "每行輸入一個相對於儲存庫的目錄。根目錄及父目錄中的檔案仍可使用。最多 64 個目錄。",
|
||||
"checkoutOptions.invalidDirectories": "請輸入有效的相對目錄,不可包含通配符或上級路徑。",
|
||||
"checkoutOptions.unavailable": "無法檢查支援情況。",
|
||||
"checkoutOptions.checking": "正在檢查儲存庫支援情況…",
|
||||
"checkoutOptions.executorRequired": "選擇執行器以檢查支援情況。",
|
||||
"checkoutOptions.providerUnsupported": "高級檢出選項目前適用於 GitHub 儲存庫。",
|
||||
"checkoutOptions.preparationUnsupported": "請選擇使用內置準備腳本的 Worktree 或本地 Docker 執行器。",
|
||||
"checkoutOptions.reset": "重置",
|
||||
"checkoutOptions.cancel": "取消",
|
||||
"checkoutOptions.apply": "應用",
|
||||
"checkoutOptions.folderCount_one": "{{count}} 個資料夾",
|
||||
"checkoutOptions.folderCount_other": "{{count}} 個資料夾",
|
||||
"checkoutOptions.openFor": "{{repository}} 的儲存庫選項",
|
||||
"checkoutOptions.repositoryRequired": "請先選擇儲存庫",
|
||||
"checkoutOptions.invalidLine": "第 {{line}} 行的目錄無效。",
|
||||
"checkoutOptions.credentialsUnsupported": "請使用由 Kandev 管理的 Git 認證資料啓用高級檢出選項。",
|
||||
"toolPayloadRemovedAt": "工具詳細數據已於 {{date}} 刪除。",
|
||||
"toolPayloadRemoved": "工具詳細數據已刪除。"
|
||||
}
|
||||
|
||||
@@ -2279,6 +2279,32 @@
|
||||
"planCommentMigrationFailed": "無法恢復部分已儲存的計劃評論。",
|
||||
"planCommentMigrationNeedsPlan": "請建立或恢復計劃,以恢復已儲存的評論。",
|
||||
"planCommentMigrationPending": "仍在恢復已儲存的計劃評論。您的訊息會保留。",
|
||||
"checkoutOptions.title": "儲存庫選項",
|
||||
"checkoutOptions.taskOnly": "僅用於此任務",
|
||||
"checkoutOptions.download": "下載方式",
|
||||
"checkoutOptions.standard": "標準",
|
||||
"checkoutOptions.onDemand": "按需",
|
||||
"checkoutOptions.downloadHelp": "按需下載僅在需要時擷取檔案內容,並保留完整的提交歷史。",
|
||||
"checkoutOptions.folders": "檢出資料夾",
|
||||
"checkoutOptions.allFolders": "所有資料夾",
|
||||
"checkoutOptions.selectedFolders": "選定的資料夾",
|
||||
"checkoutOptions.directories": "目錄",
|
||||
"checkoutOptions.foldersHelp": "每行輸入一個相對於儲存庫的目錄。根目錄及父目錄中的檔案仍可使用。最多 64 個目錄。",
|
||||
"checkoutOptions.invalidDirectories": "請輸入有效的相對目錄,不可包含萬用字元或上級路徑。",
|
||||
"checkoutOptions.unavailable": "無法檢查支援情況。",
|
||||
"checkoutOptions.checking": "正在檢查儲存庫支援情況…",
|
||||
"checkoutOptions.executorRequired": "選擇執行器以檢查支援情況。",
|
||||
"checkoutOptions.providerUnsupported": "高階檢出選項目前適用於 GitHub 儲存庫。",
|
||||
"checkoutOptions.preparationUnsupported": "請選擇使用內建準備指令碼的 Worktree 或本地 Docker 執行器。",
|
||||
"checkoutOptions.reset": "重置",
|
||||
"checkoutOptions.cancel": "取消",
|
||||
"checkoutOptions.apply": "應用",
|
||||
"checkoutOptions.folderCount_one": "{{count}} 個資料夾",
|
||||
"checkoutOptions.folderCount_other": "{{count}} 個資料夾",
|
||||
"checkoutOptions.openFor": "{{repository}} 的儲存庫選項",
|
||||
"checkoutOptions.repositoryRequired": "請先選擇儲存庫",
|
||||
"checkoutOptions.invalidLine": "第 {{line}} 行的目錄無效。",
|
||||
"checkoutOptions.credentialsUnsupported": "請使用由 Kandev 管理的 Git 認證資訊啟用高階檢出選項。",
|
||||
"toolPayloadRemovedAt": "工具詳細資料已於 {{date}} 刪除。",
|
||||
"toolPayloadRemoved": "工具詳細資料已刪除。"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
---
|
||||
created: 2026-09-15
|
||||
status: complete
|
||||
requirements:
|
||||
- REQ-TASKS-REMOTE-OPTIONS-001
|
||||
- REQ-TASKS-REMOTE-OPTIONS-002
|
||||
- REQ-TASKS-REMOTE-OPTIONS-003
|
||||
system_design:
|
||||
- ../../specs/tasks/system-design/remote-repository-options.md
|
||||
legacy_specs: []
|
||||
---
|
||||
|
||||
# Implementation plan: Remote repository options
|
||||
|
||||
## Overview
|
||||
|
||||
Add a gear to each remote repository chip in task creation, backed by task-owned
|
||||
checkout options. Implement the contract first, host preparation second, remote
|
||||
preparation third, and expose the complete responsive UI last. Work sequentially.
|
||||
|
||||
User-confirmed: per-remote-repository gear; settings apply only to this task.
|
||||
Implemented controls: Standard/Download on demand and All/Selected folders. Standard
|
||||
preserves existing behavior; on-demand is opt-in. This package does not redesign
|
||||
preparation progress or remove the existing five-minute timeout.
|
||||
|
||||
## Scope
|
||||
|
||||
### In scope
|
||||
|
||||
- Task attachment persistence, validation, capability admission, and resume.
|
||||
- Real filtered downloads and per-task sparse checkout before file population.
|
||||
- Existing managed credential leases for later object reads.
|
||||
- Desktop panel, phone drawer, row summaries, and localized validation.
|
||||
|
||||
### Out of scope
|
||||
|
||||
- Repository defaults, automatic directory browsing/dependency selection, shallow-depth UI, and arbitrary flags.
|
||||
- Editing materialized workspaces, changes to user-owned local checkouts, and general startup timeout/progress redesign.
|
||||
- Enabling options on providers or custom scripts without demonstrated support.
|
||||
|
||||
## Technical approach
|
||||
|
||||
Follow the [system design](../../specs/tasks/system-design/remote-repository-options.md).
|
||||
Add a typed metadata codec and request/response field; no SQL schema change.
|
||||
Preserve `Repository.LocalPath` as shared state and carry effective partial-clone
|
||||
cache paths on the task launch request. Apply sparse configuration on the task
|
||||
worktree, never the shared clone. Host credentials, primary remote bootstrap,
|
||||
and sibling agentctl materialization are separate paths that all need evidence.
|
||||
Capability admission rejects any path that cannot enforce the requested policy.
|
||||
|
||||
## ASCII UI preview
|
||||
|
||||
### UI-01: Desktop, selected remote row and expanded settings
|
||||
|
||||
```text
|
||||
[raycast/extensions] [main v] [gear*] [x]
|
||||
On demand, 1 folder
|
||||
|
||||
Repository settings: raycast/extensions
|
||||
Applies to this task only
|
||||
Download [Download on demand v]
|
||||
Folders [Selected folders v]
|
||||
[extensions/my-extension ]
|
||||
[packages/shared ]
|
||||
One directory per line
|
||||
[Reset] [Cancel] [Apply]
|
||||
```
|
||||
|
||||
The gear is always visible on a resolved remote row; `*` illustrates a subtle
|
||||
non-default indicator. The panel is bounded and the parent dialog stays open.
|
||||
Root/ancestor files are included by directory-based sparse checkout; shared
|
||||
subdirectories can be added explicitly. No success claim is based on this sketch.
|
||||
|
||||
### UI-02: Phone, gear opens an inset bottom drawer
|
||||
|
||||
```text
|
||||
[raycast/extensions]
|
||||
[main v] [gear*] [x]
|
||||
On demand, 1 folder
|
||||
|
||||
+--------------------------------+
|
||||
| Repository settings |
|
||||
| raycast/extensions |
|
||||
| Applies to this task only |
|
||||
|--------------------------------|
|
||||
| Download |
|
||||
| [Download on demand v] |
|
||||
| Folders |
|
||||
| [Selected folders v] |
|
||||
| [extensions/my-extension ] |
|
||||
| One directory per line |
|
||||
| [Reset] |
|
||||
|--------------------------------|
|
||||
| [Cancel] [Apply] |
|
||||
+--------------------------------+
|
||||
```
|
||||
|
||||
Header and safe-area footer are fixed; only the middle region scrolls. Existing
|
||||
mobile picker-sheet geometry is the exemplar. Controls have 44px touch targets;
|
||||
ordinary desktop controls retain 28px density. Folder edits use this same drawer,
|
||||
not another stacked picker. Label width and ASCII spacing are illustrative.
|
||||
Control grouping, row ownership, scroll ownership, and action order are required.
|
||||
|
||||
### UI-03: Validation and capability states
|
||||
|
||||
```text
|
||||
[../outside]
|
||||
Use a folder path inside this repository.
|
||||
[Apply disabled]
|
||||
|
||||
Download on demand unavailable
|
||||
This executor cannot fetch missing files after setup.
|
||||
[Standard v]
|
||||
```
|
||||
|
||||
Empty repository row: no gear until a repository URL is selected or submitted.
|
||||
Capability loading: retain current applied settings, disable Apply for custom settings until current
|
||||
identity/executor eligibility is known. Capability error: Retry without resetting
|
||||
folder inputs. Changing executors does not silently clear incompatible settings.
|
||||
These views cover AC-001.1 through AC-001.4, AC-002.2/002.4, and AC-003.2.
|
||||
|
||||
## Tests
|
||||
|
||||
All new/changed logic uses TDD: demonstrate the relevant failure first, implement,
|
||||
then refactor and run the work order's exact checks. Targeted test suites:
|
||||
|
||||
| Acceptance | Evidence |
|
||||
| --- | --- |
|
||||
| 001.1-001.4 | desktop/phone E2E: opener, draft apply/cancel/reset, row summary; desktop/phone E2E |
|
||||
| 002.1-002.4 | `repository_checkout_options_test.go`: codec, request/DTO persistence, legacy absent values, ownership and capability rejection; `task-create-dialog-checkout-options.test.ts`: identity/reset/payload |
|
||||
| 003.1, 003.3 | `clone_checkout_options_test.go`: filter-capable server, omitted historical blob, later lazy fetch, authorized lazy fetch; credential lease tests cover expiry/revocation, mode-specific cache reuse |
|
||||
| 003.1, 003.2, 003.4, 003.5 | `manager_checkout_options_test.go`: first checkout is sparse, two task scopes, dirty reuse, absent directories, scope mismatch |
|
||||
| 002.1/002.4, 003.1-003.5 | `repository_checkout_options_test.go` in lifecycle plus `workspace_checkout_options_test.go` in agentctl API: primary/sibling propagation and remote staging |
|
||||
|
||||
Numbers abbreviate the `AC-TASKS-REMOTE-OPTIONS-` prefix. Full IDs live in each
|
||||
work order. Tests must assert outcomes, not only generated Git argument strings.
|
||||
|
||||
## E2E tests
|
||||
|
||||
- `e2e/tests/task/remote-repository-options.spec.ts` (`chromium`): two remote rows,
|
||||
one customized, payload persisted after task creation, unaffected sibling,
|
||||
cancel/reset, invalid paths, and repeat creation defaults. Unsupported executor
|
||||
responses and stale capability requests have service/hook tests.
|
||||
- `e2e/tests/task/mobile-remote-repository-options.spec.ts` (`mobile-chrome`): visible gear and drawer, Apply, focus return, bounded drawer, no document
|
||||
overflow, and measured 44px targets. A real software keyboard and explicit
|
||||
767/768px breakpoint measurements have not been exercised.
|
||||
- `repository_checkout_options_container_test.go`: the generated production
|
||||
primary-clone script runs in `kandev-agent:e2e` against a disposable authenticated
|
||||
Git server. It verifies selected files, omitted historical objects, lazy reads
|
||||
after clone-helper disposal, and revocation without secrets in Git config.
|
||||
Native agentctl tests separately cover sibling materialization and reuse.
|
||||
No live Raycast clone or personal credentials are used.
|
||||
|
||||
|
||||
## Work orders
|
||||
|
||||
| Order | Work order | Dependency | Status |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | [Task policy contract](task-01-policy-contract.md) | None | done |
|
||||
| 2 | [Host preparation](task-02-host-preparation.md) | 01 | done |
|
||||
| 3 | [Remote preparation](task-03-remote-preparation.md) | 02 | done |
|
||||
| 4 | [Repository gear and responsive settings](task-04-repository-gear.md) | 03 | done |
|
||||
|
||||
## Verification results
|
||||
|
||||
Design validation on 2026-09-15:
|
||||
|
||||
- `python3 scripts/list-docs.py validate`: passed (269 decisions, 925 specifications).
|
||||
- `python3 scripts/lint-spec-files.py --all`: passed.
|
||||
- `python3 scripts/lint-spec-files.test.py`: passed (36 tests).
|
||||
- Local package link and acceptance-ID validation: passed for all seven files.
|
||||
- `git diff --check -- docs/specs docs/plans/remote-repository-options`: passed.
|
||||
- `git status --short -- docs/specs docs/plans/remote-repository-options`: confirmed the seven new design files are untracked and available for review.
|
||||
|
||||
Implementation validation on 2026-09-15:
|
||||
|
||||
- Targeted race tests passed across ten backend packages: clone, worktree,
|
||||
executor, credentials, lifecycle, agentctl client/API, models, service, handlers.
|
||||
- Authenticated Docker primary preparation test passed with selected folders,
|
||||
omitted historical blobs, post-bootstrap lazy fetch, and revoked access.
|
||||
- Frontend regression unit suites: 74 tests passed. Capability hook tests:
|
||||
stale executor responses and failed-request retry passed.
|
||||
- Seven phone browser tests passed, including existing remote picker regressions,
|
||||
the new drawer, measured touch targets, focus restoration, and viewport bounds.
|
||||
- All ten existing desktop remote picker tests passed. The new two-repository
|
||||
settings test passed after waiting for closing popovers to unmount. It checks
|
||||
independent row settings, persistence, invalid paths, Cancel/Reset, and fresh
|
||||
task defaults. The final phone drawer smoke test also passed on that build.
|
||||
- Final frontend typecheck, focused ESLint, and i18n checks passed. Existing
|
||||
i18n orphan-key warnings remain; no new untranslated copy was introduced.
|
||||
- Public docs validator and its test passed (46 pages). Specification catalog
|
||||
and all-file linter passed (269 decisions, 925 specifications).
|
||||
- `git diff --check` passed. No commit or push was requested or performed.
|
||||
|
||||
The Docker test exercises the generated production preparation script inside a
|
||||
container; it does not claim a complete live-backend Docker task launch. The real
|
||||
Raycast repository has not been recloned. Individual work orders own commands.
|
||||
|
||||
## Risks
|
||||
|
||||
- Transient clone credentials alone cannot support partial clones; the later
|
||||
credential test is a release prerequisite, not an optional follow-up.
|
||||
- Shared cache conversion or shared sparse patterns could affect other tasks;
|
||||
mode-specific cache paths and worktree-specific patterns prevent that coupling.
|
||||
- Primary remote bootstrap and sibling materialization differ today. Capability
|
||||
advertisement must follow coverage; a successful sibling test is insufficient.
|
||||
- Sparse checkout can omit build dependencies. Explain shared-folder selection
|
||||
and Git cone semantics; do not infer dependency completeness.
|
||||
- Partial cloning can still be slow. The existing five-minute limit remains until
|
||||
a separate preparation-lifecycle package changes it.
|
||||
|
||||
### PR review remediation
|
||||
|
||||
Merged current main and retained both translation additions. Review fixes separate
|
||||
managed checkout credentials from setup-script environment, validate Docker scopes
|
||||
before setup, preserve attachment policy across branch changes, reject ambiguous
|
||||
replacement attachments, retain git-crypt unlock state, accept exact submodule
|
||||
scopes, preserve path whitespace, persist executor-only task defaults, and suppress
|
||||
credential-bearing clone diagnostics. Aggregate-review fixes also resolve the
|
||||
mode-specific path during refresh, normalize reuse identities, log submodule
|
||||
discovery errors, and correct capability translations. Targeted regressions
|
||||
reproduced the functional issues.
|
||||
|
||||
Validation: focused Go checkout/handler tests with race detection, web parser/chip
|
||||
and capability tests, TypeScript typecheck, i18n checks, and specification validation.
|
||||
|
||||
The follow-up review's test-isolation finding was reproduced with inherited
|
||||
`GIT_DIR`, `GIT_WORK_TREE`, `GIT_COMMON_DIR`, `GIT_INDEX_FILE`,
|
||||
`GIT_OBJECT_DIRECTORY`, and `GIT_ALTERNATE_OBJECT_DIRECTORIES`. Checkout fixtures
|
||||
now clear those paths before setup; both lifecycle and repoclone checkout tests
|
||||
pass with all six variables pointing to invalid temporary paths. This changes
|
||||
only test isolation, so the production contract and screenshots are unchanged.
|
||||
@@ -0,0 +1,74 @@
|
||||
---
|
||||
id: "01-policy-contract"
|
||||
title: "Task policy contract"
|
||||
status: done
|
||||
wave: 1
|
||||
depends_on: []
|
||||
plan: plan.md
|
||||
requirements:
|
||||
- REQ-TASKS-REMOTE-OPTIONS-002
|
||||
- REQ-TASKS-REMOTE-OPTIONS-003
|
||||
acceptance_criteria:
|
||||
- AC-TASKS-REMOTE-OPTIONS-002.1
|
||||
- AC-TASKS-REMOTE-OPTIONS-002.2
|
||||
- AC-TASKS-REMOTE-OPTIONS-002.3
|
||||
- AC-TASKS-REMOTE-OPTIONS-002.4
|
||||
- AC-TASKS-REMOTE-OPTIONS-003.2
|
||||
system_design:
|
||||
- ../../specs/tasks/system-design/remote-repository-options.md
|
||||
---
|
||||
|
||||
# Task 01: Task policy contract
|
||||
|
||||
## Summary and scope
|
||||
|
||||
Own the typed options codec, request/response propagation, task metadata
|
||||
persistence, capability evaluation/endpoint, and pre-side-effect validation.
|
||||
Likely files: `apps/backend/internal/task/models/repository_checkout_options.go`
|
||||
(new), `internal/task/service/service_requests.go`, `service_tasks.go`, task
|
||||
handler request adapters, `pkg/api/v1/task.go`, task DTO converters,
|
||||
and task repository round-trip tests. Follow existing workspace/provider
|
||||
inspection authorization; capability evaluation performs no clone.
|
||||
|
||||
Keep all capabilities false until the implementing path passes its required
|
||||
runtime tests. Existing/omitted options retain behavior. Exclude Git command
|
||||
changes and rendered controls. No schema migration is needed.
|
||||
|
||||
## Dependencies
|
||||
|
||||
No implementation dependencies. Read the requirement and design before coding.
|
||||
|
||||
## Implementation acceptance
|
||||
|
||||
1. Typed options survive create/read/update-preservation/restart/resume round trips without writing workspace repository defaults or losing unrelated metadata.
|
||||
2. Invalid enums/paths/limits, local-source misuse, unsupported executor/provider combinations, and post-materialization edits fail before launch side effects.
|
||||
3. Capability results are identity/profile scoped and submission independently recomputes them.
|
||||
|
||||
## Verification
|
||||
|
||||
Use TDD: reproduce the required failure before implementation, then verify the
|
||||
real behavior. New files named below are created by this work order. Commands
|
||||
run from the repository root; install once with `(cd apps && pnpm install
|
||||
--frozen-lockfile)` before any package command if this worktree lacks dependencies.
|
||||
|
||||
```sh
|
||||
(cd apps/backend && go test ./internal/task/models ./internal/task/service ./internal/task/handlers ./internal/task/dto ./pkg/api/v1 -run 'Test.*(RepositoryCheckoutOptions|CheckoutCapabilities)' -count=1)
|
||||
```
|
||||
|
||||
## Risks
|
||||
|
||||
Preserve task identity, provider authorization, credential redaction, existing
|
||||
branch/contribution semantics, and user work. A capability or environment blocker
|
||||
is not a passing result; record it and do not advertise unverified support.
|
||||
|
||||
## Results
|
||||
|
||||
Passed the targeted Go command above. Regression failures observed first:
|
||||
metadata lost options, invalid options accepted, request/API conversions lost
|
||||
options, updates discarded options, local repositories accepted options, and
|
||||
the capabilities route returned 404. All now pass. Runtime capabilities are enabled only for GitHub Worktree and Local Docker
|
||||
with built-in preparation scripts and managed credentials, verified below.
|
||||
|
||||
Source correction: this checkout has no `internal/task/controller` package;
|
||||
HTTP/WS request adapters live in `internal/task/handlers`. The command was
|
||||
corrected to the existing package rather than creating an artificial directory.
|
||||
@@ -0,0 +1,73 @@
|
||||
---
|
||||
id: "02-host-preparation"
|
||||
title: "Host preparation"
|
||||
status: done
|
||||
wave: 2
|
||||
depends_on: ["01-policy-contract"]
|
||||
plan: plan.md
|
||||
requirements:
|
||||
- REQ-TASKS-REMOTE-OPTIONS-002
|
||||
- REQ-TASKS-REMOTE-OPTIONS-003
|
||||
acceptance_criteria:
|
||||
- AC-TASKS-REMOTE-OPTIONS-002.1
|
||||
- AC-TASKS-REMOTE-OPTIONS-002.3
|
||||
- AC-TASKS-REMOTE-OPTIONS-002.4
|
||||
- AC-TASKS-REMOTE-OPTIONS-003.1
|
||||
- AC-TASKS-REMOTE-OPTIONS-003.2
|
||||
- AC-TASKS-REMOTE-OPTIONS-003.3
|
||||
- AC-TASKS-REMOTE-OPTIONS-003.4
|
||||
- AC-TASKS-REMOTE-OPTIONS-003.5
|
||||
system_design:
|
||||
- ../../specs/tasks/system-design/remote-repository-options.md
|
||||
---
|
||||
|
||||
# Task 02: Host preparation
|
||||
|
||||
## Summary and scope
|
||||
|
||||
Own `apps/backend/internal/repoclone/clone.go`, new
|
||||
`clone_checkout_options_test.go`, `internal/worktree/worktree.go`,
|
||||
`manager_lifecycle.go`, new `manager_checkout_options_test.go`, and option
|
||||
propagation in `internal/orchestrator/executor/executor.go`,
|
||||
`executor_resume.go`, and `executor_execute.go`. Extend existing managed
|
||||
credential environment composition only as needed; do not weaken its account
|
||||
isolation or make the personal host bridge a fallback.
|
||||
|
||||
Implement the mode-specific cache path, staged readiness validation, worktree
|
||||
scope before checkout, and post-clone authorized lazy reads. Amend the existing
|
||||
no-promisor test to preserve its guarantee for Standard and add positive tests
|
||||
only for the newly supported mode. Exclude UI and remote executor scripts.
|
||||
Enable host capabilities only for isolated and credential-proven paths.
|
||||
|
||||
## Dependencies
|
||||
|
||||
Complete the preceding work order; no parallel execution is planned.
|
||||
|
||||
## Implementation acceptance
|
||||
|
||||
1. A filter-capable authenticated fixture proves omitted historical blobs are not downloaded and later reads succeed after initial credential cleanup; revoked auth fails without fallback.
|
||||
2. Two tasks sharing a repository retain different sparse scopes and unchanged branch/contribution authority through cancellation, retry, restart, and resume.
|
||||
3. Invalid directories, incomplete clones, and dirty existing worktrees fail safely; shared repository paths and other task files remain unchanged.
|
||||
|
||||
## Verification
|
||||
|
||||
Use TDD: reproduce the required failure before implementation, then verify the
|
||||
real behavior. New files named below are created by this work order. Commands
|
||||
run from the repository root; install once with `(cd apps && pnpm install
|
||||
--frozen-lockfile)` before any package command if this worktree lacks dependencies.
|
||||
|
||||
```sh
|
||||
(cd apps/backend && go test -race ./internal/repoclone ./internal/worktree ./internal/orchestrator/executor ./internal/gitcredentials -run 'Test.*(CheckoutOptions|AuthenticatedClone|RepositoryCheckout|CheckoutCapabilities)' -count=1)
|
||||
```
|
||||
|
||||
## Risks
|
||||
|
||||
Preserve task identity, provider authorization, credential redaction, existing
|
||||
branch/contribution semantics, and user work. A capability or environment blocker
|
||||
is not a passing result; record it and do not advertise unverified support.
|
||||
|
||||
## Results
|
||||
|
||||
Targeted race tests passed for repoclone, worktree, executor, and credentials.
|
||||
Real Git fixtures cover filtered historical objects, lazy reads, cache identity,
|
||||
independent sparse scopes, dirty reuse, and excluded submodules.
|
||||
@@ -0,0 +1,78 @@
|
||||
---
|
||||
id: "03-remote-preparation"
|
||||
title: "Remote preparation"
|
||||
status: done
|
||||
wave: 3
|
||||
depends_on: ["02-host-preparation"]
|
||||
plan: plan.md
|
||||
requirements:
|
||||
- REQ-TASKS-REMOTE-OPTIONS-002
|
||||
- REQ-TASKS-REMOTE-OPTIONS-003
|
||||
acceptance_criteria:
|
||||
- AC-TASKS-REMOTE-OPTIONS-002.1
|
||||
- AC-TASKS-REMOTE-OPTIONS-002.3
|
||||
- AC-TASKS-REMOTE-OPTIONS-002.4
|
||||
- AC-TASKS-REMOTE-OPTIONS-003.1
|
||||
- AC-TASKS-REMOTE-OPTIONS-003.2
|
||||
- AC-TASKS-REMOTE-OPTIONS-003.3
|
||||
- AC-TASKS-REMOTE-OPTIONS-003.4
|
||||
- AC-TASKS-REMOTE-OPTIONS-003.5
|
||||
system_design:
|
||||
- ../../specs/tasks/system-design/remote-repository-options.md
|
||||
---
|
||||
|
||||
# Task 03: Remote preparation
|
||||
|
||||
## Summary and scope
|
||||
|
||||
Own option propagation in `apps/backend/internal/agent/runtime/lifecycle/types.go`,
|
||||
`workspace_materialization.go`, `default_scripts.go`, the relevant built-in
|
||||
executor preparation adapters, `internal/agent/runtime/agentctl/client_workspace_sources.go`,
|
||||
`internal/agentctl/server/api/workspace_materialize.go`, and associated tests.
|
||||
Own `repository_checkout_options_container_test.go`, which runs the generated
|
||||
built-in script in the existing E2E image with an authenticated Git fixture. Read scoped agentctl guidance
|
||||
before editing the API implementation.
|
||||
|
||||
Carry policy to the primary repository bootstrap as well as every sibling.
|
||||
Configure sparse checkout before materialization and retain staged publication.
|
||||
Use executor-scoped managed credentials for subsequent lazy reads. Do not edit
|
||||
user-defined preparation scripts; reject unsupported non-default policy rather
|
||||
than bypassing it. Enable only paths covered by tests. Exclude UI changes.
|
||||
|
||||
## Dependencies
|
||||
|
||||
Complete the preceding work order; no parallel execution is planned.
|
||||
|
||||
## Implementation acceptance
|
||||
|
||||
1. Primary and sibling repositories honor independent settings and survive resume/retry with authenticated lazy reads and no token persistence.
|
||||
2. Remote preparation cannot advertise support while discarding policy in scripts, transport, or materialization; unsupported paths return stable capability reasons.
|
||||
3. The real container scenario proves sparse files and deferred objects, while targeted adapter tests cover each additionally advertised built-in executor.
|
||||
|
||||
## Verification
|
||||
|
||||
Use TDD: reproduce the required failure before implementation, then verify the
|
||||
real behavior. New files named below are created by this work order. Commands
|
||||
run from the repository root; install once with `(cd apps && pnpm install
|
||||
--frozen-lockfile)` before any package command if this worktree lacks dependencies.
|
||||
|
||||
```sh
|
||||
(cd apps/backend && go test -race ./internal/agent/runtime/lifecycle ./internal/agent/runtime/agentctl ./internal/agentctl/server/api -run 'Test.*(RepositoryCheckoutOptions|Materialize.*Options|CheckoutCapabilities)' -count=1)
|
||||
(cd apps/backend && KANDEV_E2E_CONTAINERS=1 go test ./internal/agent/runtime/lifecycle -run TestRepositoryCheckoutOptionsContainer -count=1)
|
||||
```
|
||||
|
||||
## Risks
|
||||
|
||||
Preserve task identity, provider authorization, credential redaction, existing
|
||||
branch/contribution semantics, and user work. A capability or environment blocker
|
||||
is not a passing result; record it and do not advertise unverified support.
|
||||
|
||||
## Results
|
||||
|
||||
The container scenario uses the generated production shell directly inside the
|
||||
existing `kandev-agent:e2e` image. This keeps fixture authentication and Git
|
||||
transport isolated without relying on a public GitHub endpoint. Native API tests
|
||||
cover sibling materialization separately. Targeted race tests passed for lifecycle, agentctl client, and agentctl API.
|
||||
The authenticated Docker sink test passed with later lazy reads after bootstrap
|
||||
helper disposal and rejected access after revocation. Unsupported preparation
|
||||
paths remain disabled.
|
||||
@@ -0,0 +1,184 @@
|
||||
---
|
||||
id: "04-repository-gear"
|
||||
title: "Repository gear and responsive settings"
|
||||
status: done
|
||||
wave: 4
|
||||
depends_on: ["03-remote-preparation"]
|
||||
plan: plan.md
|
||||
requirements:
|
||||
- REQ-TASKS-REMOTE-OPTIONS-001
|
||||
- REQ-TASKS-REMOTE-OPTIONS-002
|
||||
- REQ-TASKS-REMOTE-OPTIONS-003
|
||||
acceptance_criteria:
|
||||
- AC-TASKS-REMOTE-OPTIONS-001.1
|
||||
- AC-TASKS-REMOTE-OPTIONS-001.2
|
||||
- AC-TASKS-REMOTE-OPTIONS-001.3
|
||||
- AC-TASKS-REMOTE-OPTIONS-001.4
|
||||
- AC-TASKS-REMOTE-OPTIONS-002.1
|
||||
- AC-TASKS-REMOTE-OPTIONS-002.2
|
||||
- AC-TASKS-REMOTE-OPTIONS-002.3
|
||||
- AC-TASKS-REMOTE-OPTIONS-002.4
|
||||
- AC-TASKS-REMOTE-OPTIONS-003.2
|
||||
system_design:
|
||||
- ../../specs/tasks/system-design/remote-repository-options.md
|
||||
---
|
||||
|
||||
# Task 04: Repository gear and responsive settings
|
||||
|
||||
## Summary and scope
|
||||
|
||||
Own `apps/web/components/task-create-dialog-remote-repo-chip.tsx`, a new
|
||||
`task-create-dialog-repository-options.tsx`, `task-create-dialog-types.ts`,
|
||||
`task-create-dialog-repositories-state.ts`, `task-create-dialog-helpers.ts`,
|
||||
shared create-dialog callers, API client/types and capability hook, all related
|
||||
unit tests, the two task E2E files from the plan, and locale catalogs. Follow
|
||||
shared state patterns; fetch capability data in a domain hook, not a component.
|
||||
|
||||
Use the row key for applied settings and a separate transient draft for the open
|
||||
panel. Show the gear after branch and before Remove; use a drawer on phones.
|
||||
Reuse existing dialog dismissal handling and leave the parent form open on
|
||||
settings dismissal. Reset on repository identity changes, preserve on branch
|
||||
changes, and omit policy from independent new tasks and repository-set defaults.
|
||||
|
||||
Update `docs/public/tasks-and-workflows.md` as a how-to under `/docs-maintainer`
|
||||
only once behavior is implemented. Document supported combinations and the
|
||||
remaining timeout limitation. Exclude runtime redesign and repository defaults.
|
||||
|
||||
## Dependencies
|
||||
|
||||
Complete the preceding work order; no parallel execution is planned.
|
||||
|
||||
## Implementation acceptance
|
||||
|
||||
1. Desktop and phone users apply options to one remote row, see its summary, and create a task with the persisted policy while other rows retain their own settings.
|
||||
2. Cancel/reset, repository changes, executor capability changes, invalid paths, keyboard dismissal, and repeated task creation satisfy the ownership/validation criteria.
|
||||
3. Rendered phone tests prove drawer composition, reachable actions, measured touch targets, scroll containment, and no desktop state corruption; translations pass.
|
||||
|
||||
## Verification
|
||||
|
||||
Use TDD: reproduce the required failure before implementation, then verify the
|
||||
real behavior. New files named below are created by this work order. Commands
|
||||
run from the repository root; install once with `(cd apps && pnpm install
|
||||
--frozen-lockfile)` before any package command if this worktree lacks dependencies.
|
||||
|
||||
```sh
|
||||
(cd apps/web && pnpm exec vitest run components/task-create-dialog-checkout-options.test.ts components/task-create-dialog-remote-repo-chip.test.tsx components/task-create-dialog-remote-repo-chips.test.tsx components/task-create-dialog-helpers.multi-repo.test.ts)
|
||||
(cd apps/web && pnpm run typecheck && pnpm run i18n:check)
|
||||
make -C apps/backend build
|
||||
make -C apps/backend e2e-plugin-package
|
||||
(cd apps/web && pnpm run build:e2e)
|
||||
(cd apps/web && pnpm e2e:run --project=chromium tests/task/remote-repository-options.spec.ts tests/task/create-task-remote-repo.spec.ts)
|
||||
(cd apps/web && pnpm e2e:run --project=mobile-chrome tests/task/mobile-remote-repository-options.spec.ts tests/task/mobile-create-task-remote-repo.spec.ts)
|
||||
```
|
||||
|
||||
## ASCII UI preview
|
||||
|
||||
See the [combined preview](plan.md#ascii-ui-preview).
|
||||
|
||||
### UI-01: Desktop, selected remote row and expanded settings
|
||||
|
||||
```text
|
||||
[raycast/extensions] [main v] [gear*] [x]
|
||||
On demand, 1 folder
|
||||
|
||||
Repository settings: raycast/extensions
|
||||
Applies to this task only
|
||||
Download [Download on demand v]
|
||||
Folders [Selected folders v]
|
||||
[extensions/my-extension ]
|
||||
[packages/shared ]
|
||||
One directory per line; invalid lines are identified.
|
||||
[Reset] [Cancel] [Apply]
|
||||
```
|
||||
|
||||
The gear is always visible on a resolved remote row; `*` illustrates a subtle
|
||||
non-default indicator. The panel is bounded and the parent dialog stays open.
|
||||
Root/ancestor files are included by directory-based sparse checkout; shared
|
||||
subdirectories can be added explicitly. No success claim is based on this sketch.
|
||||
|
||||
### UI-02: Phone, gear opens an inset bottom drawer
|
||||
|
||||
```text
|
||||
[raycast/extensions]
|
||||
[main v] [gear*] [x]
|
||||
On demand, 1 folder
|
||||
|
||||
+--------------------------------+
|
||||
| Repository settings |
|
||||
| raycast/extensions |
|
||||
| Applies to this task only |
|
||||
|--------------------------------|
|
||||
| Download |
|
||||
| [Download on demand v] |
|
||||
| Folders |
|
||||
| [Selected folders v] |
|
||||
| Directories, one per line |
|
||||
| [extensions/my-extension ] |
|
||||
| [packages/shared ] |
|
||||
| [Reset] |
|
||||
|--------------------------------|
|
||||
| [Cancel] [Apply] |
|
||||
+--------------------------------+
|
||||
```
|
||||
|
||||
Header and safe-area footer are fixed; only the middle region scrolls. Existing
|
||||
mobile picker-sheet geometry is the exemplar. Controls have 44px touch targets;
|
||||
ordinary desktop controls retain 28px density. Folder edits use this same drawer,
|
||||
not another stacked picker. Label width and ASCII spacing are illustrative.
|
||||
Control grouping, row ownership, scroll ownership, and action order are required.
|
||||
|
||||
### UI-03: Validation and capability states
|
||||
|
||||
```text
|
||||
[../outside]
|
||||
Use a folder path inside this repository.
|
||||
[Apply disabled]
|
||||
|
||||
Download on demand unavailable
|
||||
This executor cannot fetch missing files after setup.
|
||||
[Standard v]
|
||||
```
|
||||
|
||||
Empty repository row: no gear until a repository URL is selected or submitted.
|
||||
Capability loading: retain current applied settings, disable Apply for custom settings until current
|
||||
identity/executor eligibility is known. Capability error: Retry without resetting
|
||||
folder inputs. Changing executors does not silently clear incompatible settings.
|
||||
These views cover AC-001.1 through AC-001.4, AC-002.2/002.4, and AC-003.2.
|
||||
|
||||
## Risks
|
||||
|
||||
Preserve task identity, provider authorization, credential redaction, existing
|
||||
branch/contribution semantics, and user work. A capability or environment blocker
|
||||
is not a passing result; record it and do not advertise unverified support.
|
||||
|
||||
## Results
|
||||
|
||||
Implemented task-only row options, desktop popover, phone drawer, translated
|
||||
copy, per-line path validation, support-check retry, and payload propagation.
|
||||
|
||||
Passed: 74 frontend regression unit tests; two capability hook tests; ten existing
|
||||
desktop picker browser tests plus the new two-repository settings test; six
|
||||
existing phone picker tests plus the new drawer test. Final drawer smoke passed
|
||||
after the capability retry change. The desktop test verifies create/read
|
||||
persistence and fresh-task defaults. Typecheck, focused ESLint, i18n checks, and
|
||||
public docs validation passed.
|
||||
|
||||
Observed and fixed before completion: missing gear, phone row overflow after
|
||||
adding the gear, stale capability responses, and failed capability-request retry.
|
||||
Browser geometry assertions now wait for drawer animation; multi-row assertions
|
||||
wait for closed popovers to unmount. No fixed-duration sleeps were added.
|
||||
Real software-keyboard behavior and explicit 767/768px measurements remain
|
||||
unverified; the browser checks cover desktop and a phone viewport.
|
||||
|
||||
### UI refinement, 2026-09-17
|
||||
|
||||
User feedback: hide the gear until a repository is selected and match existing
|
||||
Kandev form styling. Empty rows now omit the gear. Shared Select controls replace
|
||||
native radios, using the task-priority selector as the exemplar. The desktop
|
||||
panel is 320px wide with text-xs labels and tighter spacing; the phone retains
|
||||
its drawer, one scroll region, and touch-sized controls. Copy is unchanged.
|
||||
|
||||
Validation: the empty-row regression failed before the fix; all 30 chip unit
|
||||
tests passed after it. Updated desktop and mobile checkout settings E2E passed.
|
||||
Focused ESLint, typecheck, spec validation, and whitespace checks passed. The
|
||||
isolated manual-test frontend was refreshed on port 48479; port 9998 was untouched.
|
||||
@@ -150,6 +150,35 @@ Use **New Task** in the sidebar. In an open task, the **Task** split button also
|
||||
|
||||
On mobile, the two non-primary actions are separate buttons labeled **Plan mode** and **Create only**; they have the same plan-mode and create-without-agent behavior.
|
||||
|
||||
### Reduce downloads for a large remote repository
|
||||
|
||||
In **New Task → Remote**, select a repository and open its gear (**Repository options**).
|
||||
These settings apply only to that repository row in this task.
|
||||
|
||||
1. Choose **On demand** to download file contents as Git needs them while retaining
|
||||
full commit history. **Standard** keeps the existing download behavior.
|
||||
2. Choose **Selected folders** and enter repository-relative directories, one per
|
||||
line, for example `extensions/my-extension`. Include shared packages your work
|
||||
needs. Up to 64 directories are supported; wildcards and parent paths are not.
|
||||
3. Select **Apply**. A summary below the repository shows the applied choices.
|
||||
**Cancel** discards draft edits; **Reset** restores Standard and All folders.
|
||||
|
||||
Advanced options are available for GitHub repositories using **Worktree** or
|
||||
**Local Docker** with the built-in preparation script and Kandev-managed Git
|
||||
credentials. Other preparation paths
|
||||
show an explanation and keep advanced controls disabled. On phones, the gear
|
||||
opens a drawer with the same settings.
|
||||
|
||||
Selected folders use Git's directory-based sparse checkout: root and ancestor
|
||||
files remain available, and omitted files are not reported as deleted. Git can
|
||||
materialize additional files during conflict resolution. This is not an access
|
||||
restriction. Settings cannot change after the task environment is created;
|
||||
create a new task for a different scope. A new independent task starts with the
|
||||
default settings.
|
||||
|
||||
These options reduce download and checkout work. They do not change the existing
|
||||
clone timeout or guarantee that every repository will finish within it.
|
||||
|
||||
### Branch policies
|
||||
|
||||
Manage named branch policies in **Settings → Workspaces → _workspace_ → Repositories**. A policy
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
---
|
||||
status: active
|
||||
system: tasks
|
||||
created: 2026-09-15
|
||||
owners:
|
||||
- kandev
|
||||
---
|
||||
|
||||
# Remote repository options
|
||||
|
||||
## Overview
|
||||
|
||||
Users configure downloads and included folders independently for each remote
|
||||
repository in a new task. Tasks own this contract because the choices belong
|
||||
to a task's repository attachments and survive that task's retry and resume.
|
||||
Workspace repository defaults and other tasks are unaffected.
|
||||
|
||||
The user confirmed a gear beside each remote repository and selected task-only
|
||||
settings rather than remembered repository defaults. The controls and defaults
|
||||
below describe the implemented task-owned behavior.
|
||||
|
||||
## Requirements
|
||||
|
||||
### REQ-TASKS-REMOTE-OPTIONS-001: Per-repository advanced controls
|
||||
|
||||
**Intent:** Keep ordinary task creation compact while making advanced options discoverable.
|
||||
|
||||
#### Acceptance criteria
|
||||
|
||||
- **AC-TASKS-REMOTE-OPTIONS-001.1:** Each selected remote repository shall expose a visible gear with an accessible name identifying the repository. Opening it shall not change repository or branch selection or remove the row. Rows without a selected or submitted repository URL shall not show the gear.
|
||||
- **AC-TASKS-REMOTE-OPTIONS-001.2:** The settings surface shall expose download mode (Standard or Download on demand) and included folders (All folders or Selected folders). Standard and All folders shall be the initial choices. Standard shall preserve the executor's existing download behavior; Download on demand shall preserve commit history while fetching file contents as needed.
|
||||
- **AC-TASKS-REMOTE-OPTIONS-001.3:** Selected folders shall accept multiple repository-relative directory paths with per-entry errors. Apply shall update only that row; Cancel or dismissal shall discard uncommitted changes. Reset shall restore the initial choices. Applied non-default settings shall have a visible summary beside or below that repository.
|
||||
- **AC-TASKS-REMOTE-OPTIONS-001.4:** Desktop shall use a compact settings panel; phone shall use an inset bottom drawer with repository context, one scrolling form region, and reachable Apply and Cancel actions. Gear and action hit targets shall be at least 44 pixels on phone/coarse pointers. Keyboard dismissal shall close only the settings surface and return focus to its opener. All copy shall be localized.
|
||||
|
||||
### REQ-TASKS-REMOTE-OPTIONS-002: Task-only ownership
|
||||
|
||||
**Intent:** Preserve task choices without changing another task's files or future defaults.
|
||||
|
||||
#### Acceptance criteria
|
||||
|
||||
- **AC-TASKS-REMOTE-OPTIONS-002.1:** Applied options shall belong to the specific task-repository attachment, remain stable across task submission, restart, retry, and session resume, and never become workspace or repository defaults.
|
||||
- **AC-TASKS-REMOTE-OPTIONS-002.2:** Within the creation form, row removal shall remove its options and changing to a different repository shall reset them. Reordering, branch changes, and toggling away from and back to Remote shall preserve applied choices for the same row. A new independent task shall start with initial choices.
|
||||
- **AC-TASKS-REMOTE-OPTIONS-002.3:** Options shall remain distinct for multiple repository attachments, including different branches of the same repository. Editing one task or preparing one attachment shall not alter another task's checkout scope. Changing options on an already materialized environment is excluded from this release.
|
||||
- **AC-TASKS-REMOTE-OPTIONS-002.4:** API callers omitting options and existing tasks without options shall retain current behavior. Invalid options shall be rejected before launch, including attempts to apply remote clone options to a user-managed local repository. An unsupported executor/provider combination shall have an explicit reason and shall never silently ignore selected options.
|
||||
|
||||
### REQ-TASKS-REMOTE-OPTIONS-003: Effective preparation
|
||||
|
||||
**Intent:** Make the controls reduce actual preparation work while retaining normal development operations.
|
||||
|
||||
#### Acceptance criteria
|
||||
|
||||
- **AC-TASKS-REMOTE-OPTIONS-003.1:** Download on demand shall avoid eagerly downloading historical file contents. If Selected folders is also chosen, preparation shall avoid first populating the entire working tree. Selected directories and root/ancestor files needed by directory-based sparse checkout shall be available before agent startup.
|
||||
- **AC-TASKS-REMOTE-OPTIONS-003.2:** Missing directories at the resolved revision, absolute paths, parent traversal, control characters, and unsupported path expressions shall produce actionable errors rather than silently falling back to All folders. An empty Selected folders list shall block Apply and submission.
|
||||
- **AC-TASKS-REMOTE-OPTIONS-003.3:** After initial clone credentials are released, checkout, diff, historical file reads, and fetch shall still obtain missing objects using the execution's authorized credentials. Credential expiry shall retain existing renewal behavior; revocation shall fail without another account fallback. Secrets shall not appear in options, repository URLs, or errors.
|
||||
- **AC-TASKS-REMOTE-OPTIONS-003.4:** Reused repositories shall retain valid downloaded objects. Reuse shall check repository identity, selected revision, and option compatibility; interrupted preparation shall not be accepted merely because a Git directory exists. Retries shall preserve user work and never overwrite another task's checkout.
|
||||
- **AC-TASKS-REMOTE-OPTIONS-003.5:** Selected folders shall remain scoped to the task's isolated working tree. Agent tools shall see the selected folders, with omitted files treated as outside the checkout rather than deleted. Git may materialize additional files during conflict handling; sparse checkout is not a security sandbox.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Remembered defaults, shallow-depth controls, arbitrary Git flags, automatic dependency discovery, and a remote directory-browser API.
|
||||
- Editing scope after materialization, converting user-owned local repositories, or changing contribution head/branch authority.
|
||||
- The separate preparation progress, cancellation, and timeout redesign from the large-repository investigation. These remain recommended follow-up work; this package alone does not remove the five-minute timeout.
|
||||
|
||||
## Implementation plans
|
||||
|
||||
- [Remote repository options](../../../plans/remote-repository-options/plan.md)
|
||||
@@ -0,0 +1,226 @@
|
||||
---
|
||||
status: current
|
||||
system: tasks
|
||||
requirements:
|
||||
- REQ-TASKS-REMOTE-OPTIONS-001
|
||||
- REQ-TASKS-REMOTE-OPTIONS-002
|
||||
- REQ-TASKS-REMOTE-OPTIONS-003
|
||||
---
|
||||
|
||||
# Remote repository options design
|
||||
|
||||
## Boundaries and grounding
|
||||
|
||||
Task-repository attachments own the selected policy. Workspace repository rows
|
||||
continue to own provider identity and reusable clones. Use existing
|
||||
[launch repository resolution](launch-repository-resolution.md) and
|
||||
[remote contribution](remote-contribution-tasks.md) authority; browser settings
|
||||
cannot replace validated provider identity, contribution head, or branch policy.
|
||||
|
||||
Current source has three relevant preparation paths:
|
||||
|
||||
- `repoclone.Cloner.clone` in `internal/repoclone/clone.go` uses a five-minute
|
||||
execution budget and suppresses `blob:none` when an auth object is supplied.
|
||||
`TestAuthenticatedCloneDoesNotLeavePromisorCheckout` deliberately covers this.
|
||||
- `internal/worktree/manager_lifecycle.go` already supports no-checkout creation
|
||||
for some paths. Sparse setup must precede the first checkout, including the
|
||||
contribution path, not be applied after a full tree is written.
|
||||
- `internal/agent/runtime/lifecycle/default_scripts.go` prepares primary remote
|
||||
repositories, while `WorkspaceRepositoryMaterialization` and agentctl's
|
||||
`materializeRepositoryInternal` prepare sibling repositories. Updating the
|
||||
sibling API alone would leave the primary repository unaffected.
|
||||
|
||||
These paths are implementation targets, not evidence that options already work.
|
||||
|
||||
## Requirement mapping
|
||||
|
||||
| Requirement | Design sections |
|
||||
| --- | --- |
|
||||
| `REQ-TASKS-REMOTE-OPTIONS-001` | Form and responsive presentation |
|
||||
| `REQ-TASKS-REMOTE-OPTIONS-002` | Data and persistence; compatibility |
|
||||
| `REQ-TASKS-REMOTE-OPTIONS-003` | Materialization; credentials; failure and tests |
|
||||
|
||||
## Data and persistence
|
||||
|
||||
Use a typed `RepositoryCheckoutOptions` contract with `version: 1`,
|
||||
`download_mode: standard | on_demand`, and `sparse_directories: string[]`.
|
||||
An empty list means All folders; UI Selected folders with no entries is invalid.
|
||||
Omitted options mean existing behavior, not a new global clone default.
|
||||
|
||||
Expose `checkout_options` on task repository request/response types, including
|
||||
`service.TaskRepositoryInput` and `pkg/api/v1.TaskRepository`. Internally store
|
||||
its canonical representation under `TaskRepository.Metadata["checkout_options"]`.
|
||||
Use typed parse/put helpers alongside the existing typed contribution metadata
|
||||
helpers. Extend `buildTaskRepositoryMetadata`, all task-repository reconstruction
|
||||
paths, and DTO mapping. Existing metadata storage needs no new table or column.
|
||||
Old rows stay absent; round trips preserve unrelated metadata keys.
|
||||
|
||||
Validate before side effects: closed enums/version, at most 64 directories,
|
||||
at most 4096 UTF-8 bytes per path and 16 KiB for the encoded options object.
|
||||
Canonical paths use `/`; reject absolute paths, empty segments, `.`/`..`, NUL,
|
||||
newlines, backslashes, and wildcard expressions. Preserve case and spaces.
|
||||
Deduplicate exact normalized paths. Pass directories using stdin/argument arrays. Built-in shell scripts quote each
|
||||
argument using the existing shell-quoting helper; never insert raw shell text. Validate existence as directories against the
|
||||
provider-resolved commit during preparation. Do not follow filesystem symlinks
|
||||
to validate paths outside the repository.
|
||||
|
||||
Resume reads the durable attachment, not browser state or the workspace repo.
|
||||
Do not expose a post-materialization update endpoint in this package. Existing
|
||||
generic edits must preserve the policy and reject attempts to change it once an
|
||||
environment exists. Explicit task-copy flows may copy attachment options only
|
||||
where their existing contract copies repository configuration; unrelated new
|
||||
task defaults and repository-set saving must not capture them.
|
||||
|
||||
## Form and responsive presentation
|
||||
|
||||
Extend `TaskRemoteRepoRow`, `useRemoteReposState`, and remote payload builders in
|
||||
`task-create-dialog-helpers.ts`. Applied state is keyed by row key. A panel owns
|
||||
its temporary draft until Apply; repository identity change clears applied and
|
||||
draft options using `applyRemoteRepoPatch`. Branch changes retain paths and
|
||||
revalidate them at preparation. Removing a row closes its panel.
|
||||
|
||||
`RemoteRepoChip` gets a gear between `RemoteBranchPill` and `RemoveButton`.
|
||||
Use an independent semantic button, never a nested button in the repo trigger.
|
||||
Render it only after a repository URL is selected or submitted. A subtle dot and a short summary (for example, "On demand, 2 folders")
|
||||
identify applied non-default choices. Existing repository and branch controls
|
||||
remain in their current order. Shared create-dialog consumers receive the same
|
||||
row behavior, including the new-subtask form; edit/session modes preserve saved
|
||||
options without offering unsupported changes.
|
||||
|
||||
Desktop uses a bounded 320px `@kandev/ui` Popover, shared Select controls,
|
||||
muted text-xs labels, and 28px control height. Phone uses the
|
||||
inset Drawer pattern in `components/task/mobile/mobile-picker-sheet.tsx`:
|
||||
repository title, one scrolling body, and a safe-area-aware fixed action footer.
|
||||
This is a short temporary settings task, so it does not need a new route.
|
||||
Use `useResponsiveBreakpoint`; coarse-pointer controls get 44px hit targets.
|
||||
The phone form shares draft logic with desktop and does not stack a folder
|
||||
picker on another drawer. Directory entry is a labelled multiline field, one directory per line, with
|
||||
invalid line numbers reported alongside the field. Focus restoration and Escape use existing modal primitives.
|
||||
Long names truncate visually but retain complete accessible names. No document
|
||||
horizontal overflow; the form handles keyboard-reduced dynamic viewport height.
|
||||
|
||||
All product text uses localization, with en/pt-pt/zh-cn and generated zh-hk/zh-tw
|
||||
catalogs. The public how-to is in the
|
||||
task-creation section of `docs/public/tasks-and-workflows.md`.
|
||||
|
||||
## Materialization
|
||||
|
||||
Propagate options by attachment identity through launch/resume repository specs,
|
||||
`worktree.CreateRequest`, primary remote preparation, and sibling materialization
|
||||
requests in lifecycle, agentctl client, and agentctl server. The runtime validates
|
||||
again at the boundary. The first repository and every sibling use the same
|
||||
effective policy; never read only `repositories[0]` for options.
|
||||
|
||||
For on-demand mode, use `git clone --filter=blob:none --no-checkout` with existing
|
||||
tag policy and provider-validated origin. Do not introduce `--depth=1` or silently
|
||||
fall back to a full transfer when the server refuses filtering. Standard preserves
|
||||
existing clone behavior, but Selected folders still requires delaying checkout.
|
||||
Configure cone-mode sparse checkout in the task worktree before its first checkout,
|
||||
then check out the resolved branch/head. Keep root and ancestor files per Git's
|
||||
cone semantics. Include only selected submodules in initialization; preserve the
|
||||
existing git-crypt unlock order and contribution binding behavior.
|
||||
|
||||
Shared host caches must not contain a task's sparse pattern or task credentials.
|
||||
Add a versioned on-demand cache variant beneath the existing workspace/provider
|
||||
repository namespace, keyed by download mode rather than directory choices.
|
||||
Leave existing standard cache locations and recorded repository paths intact;
|
||||
carry the effective variant path on the launch request, not by overwriting the
|
||||
shared `Repository.LocalPath` for another task. Worktrees use worktree-specific
|
||||
sparse configuration. Task-owned remote checkouts can apply scope directly.
|
||||
Never convert a user-managed local checkout.
|
||||
|
||||
Record a credential-free preparation marker beside the managed checkout with
|
||||
repository identity, option version/mode, and completion state. Sparse scope
|
||||
belongs to the environment/attachment's worktree record, not shared cache state.
|
||||
Use existing per-path admission/locking and temporary-directory publication.
|
||||
For existing standard clones with no marker, validate Git state and preserve the
|
||||
existing reuse behavior; an unmarked directory is not permission to delete it.
|
||||
|
||||
## Credentials and capability admission
|
||||
|
||||
On-demand mode is enabled only after post-clone lazy fetch works for the selected
|
||||
provider/executor. Follow the existing
|
||||
[GitHub managed helper](../../integrations/system-design/github-authentication-02.md)
|
||||
and [lease reissue](../../platform/requirements/git-credential-lease-reissue.md)
|
||||
contracts. Each Kandev-owned Git command obtains the current scoped environment;
|
||||
the agent and tools receive the existing execution-scoped helper. Never persist
|
||||
the transient clone password in Git config or reuse another task's lease.
|
||||
Managed credential failure never falls through to the optional personal host bridge.
|
||||
|
||||
Introduce one backend capability evaluator over trusted provider identity and
|
||||
executor preparation support; both submit validation and the UI capability
|
||||
response consume it. The endpoint is a workspace-authorized POST to
|
||||
`/api/v1/workspaces/:id/repository-checkout-capabilities`, accepting the existing
|
||||
remote locator shape plus executor profile ID. It performs no clone and returns
|
||||
supported modes, sparse support, and stable reason codes. Browser responses are
|
||||
advisory; submission recomputes eligibility. Resolve identities through the
|
||||
existing provider inspection seam, not browser-supplied permission claims.
|
||||
|
||||
GitHub with Kandev-managed built-in preparation is the required first supported
|
||||
path. Other providers and custom preparation scripts retain Standard unless
|
||||
their existing credentials/preparation satisfy the same tests. Sparse options
|
||||
require task-isolated checkout ownership; executors that directly reuse a shared
|
||||
working directory cannot advertise it. Switching executor invalidates capability
|
||||
results but preserves the user's draft, with an error until incompatible choices
|
||||
are reset. Capability gating is part of this draft release scope, not an assertion
|
||||
that every current executor is compatible.
|
||||
|
||||
## Failure, observability, and tests
|
||||
|
||||
Keep finite Git execution budgets and caller cancellation through shared
|
||||
`subproc` helpers. This package does not extend the five-minute host clone limit.
|
||||
Distinct error codes cover invalid directory, unsupported mode, unavailable
|
||||
lazy-fetch credentials, and incomplete checkout. Preserve task policy on failure.
|
||||
Do not start the agent if required preparation fails. Retry creates or validates
|
||||
only Kandev-owned preparation state; never reset an existing dirty worktree.
|
||||
|
||||
Log mode, preparation stage, and cache reuse without credential-bearing URLs or
|
||||
unbounded per-file output. UI errors use translated reason codes. Do not claim
|
||||
speedups without a measured fixture; network/server costs can remain substantial.
|
||||
|
||||
Required tests combine a local filter-capable Git server with a large omitted
|
||||
blob, token expiry/revocation simulation, and real isolated worktrees. Assert
|
||||
omitted blobs are absent before demand (disable lazy fetch for object inspection),
|
||||
then readable with the correct credential after clone auth cleanup. Prove two
|
||||
tasks with different sparse paths stay independent, including retry and resume.
|
||||
Exercise primary remote preparation and siblings separately. Browser tests prove
|
||||
row isolation, payload persistence, dismissal, validation, and phone geometry.
|
||||
|
||||
No separate ADR is needed: this feature-specific policy and its rationale are
|
||||
preserved here. Reusing a workspace-global sparse setting was rejected because
|
||||
it would change other task worktrees. A full directory-browser service is deferred
|
||||
because entering known directories provides the core outcome without cloning during selection.
|
||||
|
||||
## Implementation plan
|
||||
|
||||
- [Remote repository options](../../../plans/remote-repository-options/plan.md)
|
||||
|
||||
## Implemented preparation coverage
|
||||
|
||||
The initial capability evaluator enables GitHub with built-in Worktree and Local
|
||||
Docker preparation. Remote Docker is not implemented by the runtime; SSH,
|
||||
Kubernetes, Sprites, other providers, and custom preparation scripts remain
|
||||
unsupported for non-default options. The task picker displays the backend reason.
|
||||
|
||||
Shared caches use `_checkout_modes/<repository-path-hash>/<mode>-v1`, with a
|
||||
credential-free readiness marker inside `.git`. Task worktrees keep a separate
|
||||
options marker in their private Git directory. Reuse compares that marker and
|
||||
preserves uncommitted work. Remote siblings use staged agentctl materialization.
|
||||
Local Docker prepares its provisional container before publishing readiness.
|
||||
Folder validation follows target-revision checkout and precedes repository setup
|
||||
and contribution-destination configuration. Host checkout commands receive only
|
||||
managed credential environment and scoped credential configuration, separately
|
||||
from the profile environment passed to setup scripts. Ambiguous duplicate
|
||||
repository updates without branch identity are rejected rather than losing policy.
|
||||
Single-attachment branch changes preserve the policy. Exact submodule paths are
|
||||
valid sparse targets; directory text preserves leading and trailing spaces.
|
||||
Option-specific host caches retain normal managed clones as the owner of git-crypt
|
||||
unlock keys through a verified-origin link, and detect filters in committed
|
||||
attributes before materialization. Clone diagnostics are withheld from task logs
|
||||
because Git tracing can expose credential-bearing URLs.
|
||||
|
||||
Container sink validation runs the generated built-in preparation script in the
|
||||
existing `kandev-agent:e2e` image. Its local authenticated Git server tests sparse
|
||||
files, omitted historical blobs, lazy reads after initial-helper disposal, and
|
||||
revocation. API materialization and launch propagation have separate Go tests;
|
||||
desktop/mobile Playwright tests cover picker interactions and task persistence.
|
||||
Reference in New Issue
Block a user