feat: add per-task remote repository checkout options (#3817)

This commit is contained in:
José Almeida
2026-09-19 15:43:12 +01:00
committed by GitHub
parent 794773dd68
commit 9e48a71dbe
91 changed files with 4284 additions and 232 deletions
@@ -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
}
@@ -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,
+7
View File
@@ -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
}
+13
View File
@@ -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
}
+16 -14
View File
@@ -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,
+17 -16
View File
@@ -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])
}
+9 -8
View File
@@ -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
}
+18 -1
View File
@@ -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 {
+34 -1
View File
@@ -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
+20 -11
View File
@@ -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;
}
+3
View File
@@ -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";
};
+2 -1
View File
@@ -22,7 +22,8 @@
"task": {
"planCommentMigrationPending": "仍在恢復已儲存的計劃評論。您的訊息會保留。",
"proxy": "代理伺服器",
"pathBasedProxyWorksForApis": "路徑型代理伺服器。適用於 API,但可能導致預期由 / 提供服務的 Web 應用程式無法正常運作。"
"pathBasedProxyWorksForApis": "路徑型代理伺服器。適用於 API,但可能導致預期由 / 提供服務的 Web 應用程式無法正常運作。",
"checkoutOptions.providerUnsupported": "高階檢出選項目前適用於 GitHub 儲存庫。"
},
"workspaces": {
"failedToSaveRepository": "無法儲存此儲存庫"
+26
View File
@@ -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."
}
+26
View File
@@ -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ēď."
}
+26
View File
@@ -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."
}
+26
View File
@@ -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": "工具详细数据已删除。"
}
+26
View File
@@ -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": "工具詳細數據已刪除。"
}
+26
View File
@@ -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.
+29
View File
@@ -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.