From 98154246edd286061e358c90077c6e38719cd4eb Mon Sep 17 00:00:00 2001 From: rektdeckard Date: Wed, 15 Jul 2026 14:17:57 -0600 Subject: [PATCH 1/3] fix(agents): support attributes on BYOC deploys --- cmd/lk/agent.go | 14 ++++++++++---- go.mod | 10 ++++++---- go.sum | 18 ++++++++---------- 3 files changed, 24 insertions(+), 18 deletions(-) diff --git a/cmd/lk/agent.go b/cmd/lk/agent.go index 6c64f739..3aa83cd4 100644 --- a/cmd/lk/agent.go +++ b/cmd/lk/agent.go @@ -206,6 +206,8 @@ var ( skipSDKCheckFlag, agentPrebuiltImageFlag, agentPrebuiltImageTarFlag, + attributeFlag, + attributesFlag, }, // NOTE: since secrets may contain commas, or indeed any special character we might want to treat as a flag separator, // we disable it entirely here and require multiple --secrets flags to be used. @@ -644,7 +646,11 @@ func createAgent(ctx context.Context, cmd *cli.Command) error { return err } out.Statusf("Created agent with ID [%s]", util.Accented(agentID)) - return deployPrebuiltImage(buildContext, agentID, imageRef, imageTar) + attrs, err := resolveAttributes(cmd) + if err != nil { + return err + } + return deployPrebuiltImage(buildContext, agentID, imageRef, imageTar, attrs) } projectType, err := agentfs.DetectProjectType(os.DirFS(workingDir)) @@ -828,7 +834,7 @@ func deployAgent(ctx context.Context, cmd *cli.Command) error { return fmt.Errorf("failed to update agent secrets: %s", resp.Message) } } - if err := deployPrebuiltImage(buildContext, agentId, imageRef, imageTar); err != nil { + if err := deployPrebuiltImage(buildContext, agentId, imageRef, imageTar, attrs); err != nil { return fmt.Errorf("unable to deploy prebuilt image: %w", err) } out.Status("Deployed agent") @@ -893,7 +899,7 @@ func promoteAgent(ctx context.Context, cmd *cli.Command) error { // deployPrebuiltImage pushes a locally-built image through the cloud-agents OCI proxy. // Exactly one of imageRef (Docker daemon via the Docker API) or imageTar must be non-empty. -func deployPrebuiltImage(ctx context.Context, agentID, imageRef, imageTar string) error { +func deployPrebuiltImage(ctx context.Context, agentID, imageRef, imageTar string, attrs map[string]string) error { target, err := agentsClient.GetPushTarget(ctx, agentID) if err != nil { return fmt.Errorf("failed to get push target: %w", err) @@ -920,7 +926,7 @@ func deployPrebuiltImage(ctx context.Context, agentID, imageRef, imageTar string proxyRef := fmt.Sprintf("%s/%s:%s", target.ProxyHost, target.Name, target.Tag) out.Statusf("Pushing image [%s]", util.Accented(proxyRef)) - rt := agentsClient.NewRegistryTransport() + rt := agentsClient.NewRegistryTransport(attrs) if err := crane.Push(img, proxyRef, crane.WithTransport(rt), crane.WithAuth(authn.Anonymous), diff --git a/go.mod b/go.mod index 6f1fa5f2..2ed640a0 100644 --- a/go.mod +++ b/go.mod @@ -28,7 +28,7 @@ require ( github.com/pelletier/go-toml v1.9.5 github.com/pion/rtcp v1.2.16 github.com/pion/rtp v1.10.2 - github.com/pion/webrtc/v4 v4.2.14 + github.com/pion/webrtc/v4 v4.2.15 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/stretchr/testify v1.11.1 github.com/twitchtv/twirp v8.1.3+incompatible @@ -193,11 +193,11 @@ require ( github.com/pion/mdns/v2 v2.1.0 // indirect github.com/pion/randutil v0.1.0 // indirect github.com/pion/sctp v1.10.0 // indirect - github.com/pion/sdp/v3 v3.0.18 // indirect + github.com/pion/sdp/v3 v3.0.19 // indirect github.com/pion/srtp/v3 v3.0.11 // indirect - github.com/pion/stun/v3 v3.1.4 // indirect + github.com/pion/stun/v3 v3.1.5 // indirect github.com/pion/transport/v4 v4.0.2 // indirect - github.com/pion/turn/v5 v5.0.8 // indirect + github.com/pion/turn/v5 v5.0.9 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect @@ -268,3 +268,5 @@ require ( // TEMP (local dev): use local protocol with the WorkerInfo dev message. // Drop once github.com/livekit/protocol publishes it. // replace github.com/livekit/protocol => ../protocol + +replace github.com/livekit/server-sdk-go/v2 => ../server-sdk-go diff --git a/go.sum b/go.sum index f387965b..62109edc 100644 --- a/go.sum +++ b/go.sum @@ -361,8 +361,6 @@ github.com/livekit/protocol v1.49.1-0.20260712215709-8847d7456816 h1:MDWDlH5dmcZ github.com/livekit/protocol v1.49.1-0.20260712215709-8847d7456816/go.mod h1:jO+y05AU9Ec4JswDyuzKCZ4bhziOS0CzMqgnbj60Dzs= github.com/livekit/psrpc v0.7.2 h1:6oZ+NODJ2pLyaT6VqDq1F4Qc/3TpDUSpyphj/P9MhQc= github.com/livekit/psrpc v0.7.2/go.mod h1:rAI+m2+/cb4x9RXhLRtUx5ZwdfjjXOl4zi46IjEetaw= -github.com/livekit/server-sdk-go/v2 v2.17.0 h1:FzVQMoxHv0WIg164yGqSxLeV+h3aJomjAv1lFeR9MMw= -github.com/livekit/server-sdk-go/v2 v2.17.0/go.mod h1:5nzTfVBH2Jz+TW1SrfpqC7wrbcD1lT94KZCJ9hOMyvk= github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/magefile/mage v1.17.2 h1:fyXVu1eadI8Ap1HCCNgEhJ5McIWiYhLR8uol64ZZc40= @@ -463,20 +461,20 @@ github.com/pion/rtp v1.10.2 h1:l+f6tTDcAH6xwepaAoW791ddhuYsJlqRATOzirO04Mo= github.com/pion/rtp v1.10.2/go.mod h1:Au8fc6cEByy8RLTwKTQTEeQqDB/SJDxwL4mZuxYA5Pk= github.com/pion/sctp v1.10.0 h1:qeoD6swF/2M5bYRcAGayqSbTKX3m4AW29CiQxG1+Pfg= github.com/pion/sctp v1.10.0/go.mod h1:N20Dq6LY+JvJDAh9VVh1JELngb2rQ8dPgds5yBWiPgw= -github.com/pion/sdp/v3 v3.0.18 h1:l0bAXazKHpepazVdp+tPYnrsy9dfh7ZbT8DxesH5ZnI= -github.com/pion/sdp/v3 v3.0.18/go.mod h1:ZREGo6A9ZygQ9XkqAj5xYCQtQpif0i6Pa81HOiAdqQ8= +github.com/pion/sdp/v3 v3.0.19 h1:1VMKs3gIkTQV5M3hNKfTAPrDXSNrYtOlmOD8+mSZUGQ= +github.com/pion/sdp/v3 v3.0.19/go.mod h1:dE5WOSlzXrtiE/iuZqe9n+AcEbOjtAd3k5m5NtlV/qU= github.com/pion/srtp/v3 v3.0.11 h1:GiESUr54/K4UuPigfq/CvWUed80JenQAHXn0C2MQQIQ= github.com/pion/srtp/v3 v3.0.11/go.mod h1:EeZOi/sd6glM1EXapg051gdNWO9yWT1YSsgQ4SlJkns= -github.com/pion/stun/v3 v3.1.4 h1:/7ZL0j0dmLroKOq4GfkyKQ6asByYqntwyHSp5sYLcGY= -github.com/pion/stun/v3 v3.1.4/go.mod h1:ET7PFiXo1nrD2ZNVpbEHDuT0kCPVXhKmyWdiePNMw/U= +github.com/pion/stun/v3 v3.1.5 h1:Y1FHlhaI6+4UoC5i/zQf4F7JvdZtB24/05oyy/GF1x8= +github.com/pion/stun/v3 v3.1.5/go.mod h1:zRUghXSQU32Lx5orJsz3uYMkIihweXb3mu5gIns02fs= github.com/pion/transport/v3 v3.1.1 h1:Tr684+fnnKlhPceU+ICdrw6KKkTms+5qHMgw6bIkYOM= github.com/pion/transport/v3 v3.1.1/go.mod h1:+c2eewC5WJQHiAA46fkMMzoYZSuGzA/7E2FPrOYHctQ= github.com/pion/transport/v4 v4.0.2 h1:ifYlPqNwsy6aKQ9y8yzxXlHae5431ZrH2avkD/Rn6Tk= github.com/pion/transport/v4 v4.0.2/go.mod h1:06hFI+jCFcok2X2MekVufNZ/uzNZXivGBPfviSVcjgM= -github.com/pion/turn/v5 v5.0.8 h1:pZUCtmwWCMkrRKqh/8pL3WoGADXBe0/lOPkN7oqFjK8= -github.com/pion/turn/v5 v5.0.8/go.mod h1:1VwvxElZaOdJU0liJ/WUSm/Tsh+n2OxS5ISSDxgOWxU= -github.com/pion/webrtc/v4 v4.2.14 h1:Q6zMs+fSDsYuhZcNlvFGBxCOMHVV9oYcDa6O9/HIGTc= -github.com/pion/webrtc/v4 v4.2.14/go.mod h1:87NVKP86+g4OMrRxWhjWfUjeXP4JrV6RTlUrIW+/Jak= +github.com/pion/turn/v5 v5.0.9 h1:zNeBfRyzGn7MPyUTvmvxeltLEjlFdSLPT1tlakoaOXM= +github.com/pion/turn/v5 v5.0.9/go.mod h1:u3XjBqy2Z4+NhCUpDoOSsNuQDrPLvKStlCGWk6sTQ1E= +github.com/pion/webrtc/v4 v4.2.15 h1:Ir/MauNFCfg+kgyBYPQLiGdVWFlzEcLxqtuzAkYkky0= +github.com/pion/webrtc/v4 v4.2.15/go.mod h1:CPTcyLfIzC4scOkQ4UY4pj6WvbUGhcNLIpK28cP5h6M= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= From 3af6484f7d476df1cfb9552541dd0973eaf9eac1 Mon Sep 17 00:00:00 2001 From: rektdeckard Date: Wed, 15 Jul 2026 14:54:30 -0600 Subject: [PATCH 2/3] chore(deps): bump version --- go.mod | 4 +--- go.sum | 2 ++ version.go | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/go.mod b/go.mod index 2ed640a0..40900f39 100644 --- a/go.mod +++ b/go.mod @@ -21,7 +21,7 @@ require ( github.com/joho/godotenv v1.5.1 github.com/klauspost/compress v1.18.6 github.com/livekit/protocol v1.49.1-0.20260712215709-8847d7456816 - github.com/livekit/server-sdk-go/v2 v2.17.0 + github.com/livekit/server-sdk-go/v2 v2.18.1 github.com/mattn/go-isatty v0.0.22 github.com/moby/patternmatcher v0.6.1 github.com/modelcontextprotocol/go-sdk v1.6.1 @@ -268,5 +268,3 @@ require ( // TEMP (local dev): use local protocol with the WorkerInfo dev message. // Drop once github.com/livekit/protocol publishes it. // replace github.com/livekit/protocol => ../protocol - -replace github.com/livekit/server-sdk-go/v2 => ../server-sdk-go diff --git a/go.sum b/go.sum index 62109edc..41036b10 100644 --- a/go.sum +++ b/go.sum @@ -361,6 +361,8 @@ github.com/livekit/protocol v1.49.1-0.20260712215709-8847d7456816 h1:MDWDlH5dmcZ github.com/livekit/protocol v1.49.1-0.20260712215709-8847d7456816/go.mod h1:jO+y05AU9Ec4JswDyuzKCZ4bhziOS0CzMqgnbj60Dzs= github.com/livekit/psrpc v0.7.2 h1:6oZ+NODJ2pLyaT6VqDq1F4Qc/3TpDUSpyphj/P9MhQc= github.com/livekit/psrpc v0.7.2/go.mod h1:rAI+m2+/cb4x9RXhLRtUx5ZwdfjjXOl4zi46IjEetaw= +github.com/livekit/server-sdk-go/v2 v2.18.1 h1:/u0JVII+ErGCivHnAGr6di0wl0NYsfcRvDzEtTAFovo= +github.com/livekit/server-sdk-go/v2 v2.18.1/go.mod h1:su0IvJNWTFCHVwmqpsFvxHfXWs9pn26ms+cKBR1jILU= github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/magefile/mage v1.17.2 h1:fyXVu1eadI8Ap1HCCNgEhJ5McIWiYhLR8uol64ZZc40= diff --git a/version.go b/version.go index f0add2b6..e82529ea 100644 --- a/version.go +++ b/version.go @@ -15,5 +15,5 @@ package livekitcli const ( - Version = "2.17.0" + Version = "2.18.0" ) From fefe7e3a2ba45f4fd085aa535129a3b25c42ce2a Mon Sep 17 00:00:00 2001 From: rektdeckard Date: Thu, 16 Jul 2026 10:34:00 -0600 Subject: [PATCH 3/3] feat(agents): auto-tag deployment with git branch/commit in git repos --- autocomplete/fish_autocomplete | 2 + cmd/lk/agent.go | 112 ++++++++++++++++++--- cmd/lk/app.go | 6 +- cmd/lk/utils.go | 2 +- pkg/util/git.go | 111 +++++++++++++++++++++ pkg/util/git_test.go | 174 +++++++++++++++++++++++++++++++++ pkg/util/theme.go | 23 +++-- 7 files changed, 406 insertions(+), 24 deletions(-) create mode 100644 pkg/util/git.go create mode 100644 pkg/util/git_test.go diff --git a/autocomplete/fish_autocomplete b/autocomplete/fish_autocomplete index 327bb34c..9509c5f6 100644 --- a/autocomplete/fish_autocomplete +++ b/autocomplete/fish_autocomplete @@ -70,6 +70,8 @@ complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcomma complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from create' -f -l deployment -s d -r -d 'Agent deployment' complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from create' -f -l image -r -d 'Pre-built image from the local Docker daemon (e.g. myimage:latest). Requires Docker.' complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from create' -f -l image-tar -r -d 'Pre-built image from an OCI tar file (e.g. ./image.tar). No Docker daemon required.' +complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from create' -f -l attribute -r -d '`KEY=VALUE` attribute pair, may be repeated. Merged with --attributes, taking precedence on conflicting keys.' +complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from create' -f -l attributes -r -d '`JSON` literal or file path containing an object of string key-value pairs. Use "-" to read from stdin.' complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from create' -f -l help -s h -d 'show help' complete -x -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from create; and not __fish_seen_subcommand_from help h' -a 'help' -d 'Shows a list of commands or help for one command' complete -x -c lk -n '__fish_seen_subcommand_from agent a; and not __fish_seen_subcommand_from init create dockerfile config deploy promote status update restart rollback logs tail delete destroy versions list secrets update-secrets private-link start dev console daemon simulate help h' -a 'dockerfile' -d 'Generate Dockerfile and .dockerignore for your project' diff --git a/cmd/lk/agent.go b/cmd/lk/agent.go index 3aa83cd4..5d6b8590 100644 --- a/cmd/lk/agent.go +++ b/cmd/lk/agent.go @@ -571,10 +571,8 @@ func createAgent(ctx context.Context, cmd *cli.Command) error { if err := huh.NewForm(huh.NewGroup(util.Confirm(). Title(fmt.Sprintf("Use project [%s] (%s) to create agent deployment?", project.Name, project.URL)). Value(&useProject). - Options( - huh.NewOption("Yes", true), - huh.NewOption("No, select another...", false), - ). + Affirmative("Yes"). + Negative("No, select another..."). WithTheme(util.Theme))). Run(); err != nil { return err @@ -646,7 +644,7 @@ func createAgent(ctx context.Context, cmd *cli.Command) error { return err } out.Statusf("Created agent with ID [%s]", util.Accented(agentID)) - attrs, err := resolveAttributes(cmd) + attrs, err := resolveDeployAttributes(buildContext, cmd) if err != nil { return err } @@ -805,7 +803,7 @@ func deployAgent(ctx context.Context, cmd *cli.Command) error { buildContext, cancel := context.WithTimeout(ctx, buildTimeout) defer cancel() - attrs, err := resolveAttributes(cmd) + attrs, err := resolveDeployAttributes(buildContext, cmd) if err != nil { return err } @@ -966,9 +964,9 @@ func getAgentStatus(ctx context.Context, cmd *cli.Command) error { for _, regionalAgent := range agent.AgentDeployments { curCPU := "-" curMem := "-" - agentName := "---" - deployment := "---" - lastScrapedAt := "---" + agentName := "--" + deployment := "--" + lastScrapedAt := "--" if regionalAgent.LastScrapedAt != nil { lastScrapedAt = formatTime(regionalAgent.LastScrapedAt.AsTime()) } @@ -1236,6 +1234,98 @@ func resolveAttributes(cmd *cli.Command) (map[string]string, error) { return attrs, nil } +// Attribute keys used to tag a deployed agent version with the git metadata it +// was built from. Any of these supplied explicitly by the user takes precedence. +const ( + gitCommitAttribute = "git_commit" + gitBranchAttribute = "git_branch" + gitTreeAttribute = "git_tree" +) + +// resolveDeployAttributes resolves the user-supplied attributes and, when the +// working directory is inside a git repository, automatically tags the +// deployment with git metadata: git_commit (HEAD SHA) and git_branch (current +// branch). Any attribute the user supplied explicitly always wins. When the +// working tree has uncommitted changes the tagged commit may not reflect the +// deployed code, so the user is asked to confirm (skipped, with a warning, +// under --yes or a non-interactive terminal); if they proceed, a git_tree +// attribute is added with a content hash that uniquely identifies the dirty +// working state. +func resolveDeployAttributes(ctx context.Context, cmd *cli.Command) (map[string]string, error) { + attrs, err := resolveAttributes(cmd) + if err != nil { + return nil, err + } + + if !util.IsGitRepository(ctx, workingDir) { + return attrs, nil + } + + // setDefault assigns key=value only when value is non-empty and the user + // hasn't already supplied the key. Reports whether it assigned. + setDefault := func(key, value string) bool { + if value == "" { + return false + } + if _, ok := attrs[key]; ok { + return false + } + if attrs == nil { + attrs = map[string]string{} + } + attrs[key] = value + return true + } + + commit, _ := util.GitHeadCommit(ctx, workingDir) + branch, _ := util.GitCurrentBranch(ctx, workingDir) + + // Warn/confirm on a dirty tree before auto-tagging the commit, unless the + // user pinned the commit attribute themselves. + var treeHash string + _, commitPinned := attrs[gitCommitAttribute] + if commit != "" && !commitPinned { + if dirty, err := util.HasUncommittedChanges(ctx, workingDir); err == nil && dirty { + out.Warnf("Working tree has uncommitted changes; commit [%s] may not reflect the deployed code.", util.Accented(commit)) + if !SkipPrompts(cmd) { + proceed := false + if err := huh.NewForm( + huh.NewGroup( + util.Confirm(). + Title("Continue deploying anyway?"). + Value(&proceed). + WithTheme(util.Theme), + ), + ).Run(); err != nil { + return nil, err + } + if !proceed { + return nil, errors.New("deployment cancelled") + } + } + // Proceeding with a dirty tree: capture a content hash so the exact + // working state remains identifiable despite the mismatched commit. + treeHash, _ = util.GitWorkingTreeHash(ctx, workingDir) + } + } + + taggedCommit := setDefault(gitCommitAttribute, commit) + setDefault(gitBranchAttribute, branch) + setDefault(gitTreeAttribute, treeHash) + + if taggedCommit { + if branch != "" { + out.Statusf("Tagging deployment with commit [%s] on branch [%s]", util.Accented(commit), util.Accented(branch)) + } else { + out.Statusf("Tagging deployment with commit [%s]", util.Accented(commit)) + } + if treeHash != "" { + out.Statusf("Working tree is dirty; tagged with tree hash [%s]", util.Accented(treeHash)) + } + } + return attrs, nil +} + // attributesMatch reports whether attrs contains every key-value pair in // filter. Extra keys in attrs are allowed, so the match is inclusive. func attributesMatch(attrs, filter map[string]string) bool { @@ -1299,7 +1389,7 @@ func listAgentVersions(ctx context.Context, cmd *cli.Command) error { if b { return "✓" } - return "---" + return "--" } headers := []string{"Version", "Production", "Draining", "Active", "Status", "Attributes", "Created At", "Deployed At"} if showDigest { @@ -1571,7 +1661,7 @@ func selectAgent(ctx context.Context, cmd *cli.Command, excludeEmptyVersion bool var agentNames []huh.Option[string] for _, agent := range agents.Agents { - if excludeEmptyVersion && agent.Version == "---" { + if excludeEmptyVersion && agent.Version == "--" { continue } var deployedStr string diff --git a/cmd/lk/app.go b/cmd/lk/app.go index 957f884b..8a691e2e 100644 --- a/cmd/lk/app.go +++ b/cmd/lk/app.go @@ -169,10 +169,8 @@ func requireProjectWithOpts(ctx context.Context, cmd *cli.Command, opts ...loadO Title(fmt.Sprintf("Use project [%s]?", rp.project.Name)). Description(rp.project.URL). Value(&useDefault). - Options( - huh.NewOption("Yes", true), - huh.NewOption("No, select another...", false), - ). + Affirmative("Yes"). + Negative("No, select another..."). WithTheme(util.Theme))). Run(); err != nil { return ctx, fmt.Errorf("failed to confirm project: %w", err) diff --git a/cmd/lk/utils.go b/cmd/lk/utils.go index e637db3e..1406e8ce 100644 --- a/cmd/lk/utils.go +++ b/cmd/lk/utils.go @@ -478,7 +478,7 @@ func (s *templateStringValue) String() string { func formatTime(t time.Time) string { if t.IsZero() { - return "---" + return "--" } return t.Format(time.RFC3339) } diff --git a/pkg/util/git.go b/pkg/util/git.go new file mode 100644 index 00000000..a07150ed --- /dev/null +++ b/pkg/util/git.go @@ -0,0 +1,111 @@ +// Copyright 2025 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package util + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" +) + +// IsGitRepository reports whether dir is inside a git working tree. It returns +// false (rather than an error) when git is not installed or dir is not tracked, +// so callers can treat "no git" as a soft, non-fatal condition. +func IsGitRepository(ctx context.Context, dir string) bool { + out, err := runGit(ctx, dir, "rev-parse", "--is-inside-work-tree") + return err == nil && out == "true" +} + +// GitHeadCommit returns the abbreviated SHA of the current HEAD commit. Git +// picks the shortest prefix that is unambiguous within the repository (min 7 +// chars, growing as needed), so the value uniquely identifies the commit in +// this repo. It errors when dir is not a git repository or has no commits yet. +func GitHeadCommit(ctx context.Context, dir string) (string, error) { + return runGit(ctx, dir, "log", "-1", "--pretty=format:%h") +} + +// GitCurrentBranch returns the name of the currently checked-out branch. It +// returns an empty string (and no error) when HEAD is detached, since there is +// no meaningful branch to report. +func GitCurrentBranch(ctx context.Context, dir string) (string, error) { + branch, err := runGit(ctx, dir, "rev-parse", "--abbrev-ref", "HEAD") + if err != nil { + return "", err + } + if branch == "HEAD" { + return "", nil + } + return branch, nil +} + +// HasUncommittedChanges reports whether the working tree at dir has staged or +// unstaged changes, or untracked files (i.e. `git status` is not clean). +func HasUncommittedChanges(ctx context.Context, dir string) (bool, error) { + out, err := runGit(ctx, dir, "status", "--porcelain") + if err != nil { + return false, err + } + return out != "", nil +} + +// GitWorkingTreeHash returns an abbreviated, content-addressable SHA that +// uniquely identifies the exact current state of the working tree — including +// staged, unstaged, and untracked (but not ignored) files. This is useful for +// distinguishing dirty deploys that share the same HEAD commit. The snapshot is +// built in a throwaway index, so the user's staging area and working tree are +// left untouched. Two identical working trees produce the same hash. Like +// GitHeadCommit, the returned SHA is abbreviated to the shortest prefix that is +// unambiguous within the repository. +func GitWorkingTreeHash(ctx context.Context, dir string) (string, error) { + tmp, err := os.MkdirTemp("", "lk-git-index") + if err != nil { + return "", err + } + defer os.RemoveAll(tmp) + + // A throwaway index at a fresh (nonexistent) path lets git snapshot the + // working tree without touching the user's real index. + env := append(os.Environ(), "GIT_INDEX_FILE="+filepath.Join(tmp, "index")) + if _, err := runGitEnv(ctx, dir, env, "add", "-A"); err != nil { + return "", err + } + tree, err := runGitEnv(ctx, dir, env, "write-tree") + if err != nil { + return "", err + } + // write-tree emits the full SHA; abbreviate it the same way as commits. + // The tree object is now in the object store, so rev-parse can resolve it. + return runGit(ctx, dir, "rev-parse", "--short", tree) +} + +// runGit runs `git -C dir ` in the current process environment and +// returns its trimmed stdout. +func runGit(ctx context.Context, dir string, args ...string) (string, error) { + return runGitEnv(ctx, dir, nil, args...) +} + +// runGitEnv is runGit with an explicit environment. A nil env inherits the +// current process environment. +func runGitEnv(ctx context.Context, dir string, env []string, args ...string) (string, error) { + cmd := exec.CommandContext(ctx, "git", append([]string{"-C", dir}, args...)...) + cmd.Env = env + out, err := cmd.Output() + if err != nil { + return "", err + } + return strings.TrimSpace(string(out)), nil +} diff --git a/pkg/util/git_test.go b/pkg/util/git_test.go new file mode 100644 index 00000000..3388d931 --- /dev/null +++ b/pkg/util/git_test.go @@ -0,0 +1,174 @@ +// Copyright 2025 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package util + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +// initTestRepo creates a git repo in a fresh temp dir and returns its path. +func initTestRepo(t *testing.T) string { + t.Helper() + dir := t.TempDir() + git := func(args ...string) { + t.Helper() + cmd := exec.Command("git", append([]string{"-C", dir}, args...)...) + out, err := cmd.CombinedOutput() + require.NoErrorf(t, err, "git %v: %s", args, out) + } + git("init") + // Local identity so commits succeed regardless of global config. + git("config", "user.email", "test@example.com") + git("config", "user.name", "Test") + return dir +} + +func writeFile(t *testing.T, dir, name, content string) { + t.Helper() + require.NoError(t, os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644)) +} + +func TestIsGitRepository(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not installed") + } + ctx := context.Background() + + repo := initTestRepo(t) + require.True(t, IsGitRepository(ctx, repo)) + + // A plain temp dir with no git metadata. + require.False(t, IsGitRepository(ctx, t.TempDir())) +} + +func TestGitHeadCommit(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not installed") + } + ctx := context.Background() + repo := initTestRepo(t) + + // No commits yet: should error rather than return a bogus SHA. + _, err := GitHeadCommit(ctx, repo) + require.Error(t, err) + + writeFile(t, repo, "a.txt", "hello") + require.NoError(t, exec.Command("git", "-C", repo, "add", ".").Run()) + require.NoError(t, exec.Command("git", "-C", repo, "commit", "-m", "init").Run()) + + sha, err := GitHeadCommit(ctx, repo) + require.NoError(t, err) + // Abbreviated: shorter than a full 40-char SHA, and a prefix of it. + require.NotEmpty(t, sha) + require.Less(t, len(sha), 40) + full, err := runGit(ctx, repo, "rev-parse", "HEAD") + require.NoError(t, err) + require.True(t, strings.HasPrefix(full, sha)) +} + +func TestHasUncommittedChanges(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not installed") + } + ctx := context.Background() + repo := initTestRepo(t) + + writeFile(t, repo, "a.txt", "hello") + require.NoError(t, exec.Command("git", "-C", repo, "add", ".").Run()) + require.NoError(t, exec.Command("git", "-C", repo, "commit", "-m", "init").Run()) + + // Clean tree. + dirty, err := HasUncommittedChanges(ctx, repo) + require.NoError(t, err) + require.False(t, dirty) + + // Untracked file makes it dirty. + writeFile(t, repo, "b.txt", "world") + dirty, err = HasUncommittedChanges(ctx, repo) + require.NoError(t, err) + require.True(t, dirty) +} + +func TestGitCurrentBranch(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not installed") + } + ctx := context.Background() + repo := initTestRepo(t) + + writeFile(t, repo, "a.txt", "hello") + require.NoError(t, exec.Command("git", "-C", repo, "add", ".").Run()) + require.NoError(t, exec.Command("git", "-C", repo, "commit", "-m", "init").Run()) + // Rename to a deterministic branch name (init default varies by git version). + require.NoError(t, exec.Command("git", "-C", repo, "branch", "-M", "main").Run()) + + branch, err := GitCurrentBranch(ctx, repo) + require.NoError(t, err) + require.Equal(t, "main", branch) + + // Detached HEAD reports no branch. + sha, err := GitHeadCommit(ctx, repo) + require.NoError(t, err) + require.NoError(t, exec.Command("git", "-C", repo, "checkout", sha).Run()) + branch, err = GitCurrentBranch(ctx, repo) + require.NoError(t, err) + require.Empty(t, branch) +} + +func TestGitWorkingTreeHash(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not installed") + } + ctx := context.Background() + repo := initTestRepo(t) + + writeFile(t, repo, "a.txt", "hello") + require.NoError(t, exec.Command("git", "-C", repo, "add", ".").Run()) + require.NoError(t, exec.Command("git", "-C", repo, "commit", "-m", "init").Run()) + + // Deterministic: same working tree yields the same hash. + clean1, err := GitWorkingTreeHash(ctx, repo) + require.NoError(t, err) + clean2, err := GitWorkingTreeHash(ctx, repo) + require.NoError(t, err) + require.Equal(t, clean1, clean2) + + // Modifying a tracked file changes the hash. + writeFile(t, repo, "a.txt", "changed") + modified, err := GitWorkingTreeHash(ctx, repo) + require.NoError(t, err) + require.NotEqual(t, clean1, modified) + + // An untracked file also changes the hash. + writeFile(t, repo, "b.txt", "new") + untracked, err := GitWorkingTreeHash(ctx, repo) + require.NoError(t, err) + require.NotEqual(t, modified, untracked) + + // The user's real index/worktree must be untouched: the modification to + // a.txt is still reported as an unstaged change (" M", not staged "M "). + // Read raw output here; runGit trims the leading porcelain column. + raw, err := exec.Command("git", "-C", repo, "status", "--porcelain").Output() + require.NoError(t, err) + require.Contains(t, string(raw), " M a.txt") + require.Contains(t, string(raw), "?? b.txt") +} diff --git a/pkg/util/theme.go b/pkg/util/theme.go index 7c42b7c6..96d7d434 100644 --- a/pkg/util/theme.go +++ b/pkg/util/theme.go @@ -127,6 +127,16 @@ func buildHuhTheme(tn ThemeName, p palette) *huh.Theme { t.Focused.FocusedButton = t.Focused.FocusedButton.Foreground(lipgloss.Color("7")).Background(lipgloss.Color("4")) } + // The inactive (blurred) confirm button gets a transparent background so the + // active button — which carries a solid fill — is unambiguously the selected + // one. Without this it defaults to a filled block that reads as also-active. + transparentButton := func(s lipgloss.Style) lipgloss.Style { + return s.Background(lipgloss.NoColor{}).Bold(false) + } + t.Focused.BlurredButton = transparentButton(t.Focused.BlurredButton) + t.Blurred.BlurredButton = transparentButton(t.Blurred.BlurredButton) + t.Blurred.FocusedButton = transparentButton(t.Blurred.FocusedButton) + t.Focused.SelectSelector = t.Focused.SelectSelector.Foreground(p.Accent).SetString("▶︎ ") t.Focused.SelectedOption = t.Focused.SelectedOption.Foreground(p.Accent) t.Focused.SelectedPrefix = t.Focused.SelectedPrefix.Foreground(p.Accent).SetString("[x] ") @@ -175,12 +185,9 @@ func Hyperlink(url, label string) string { return "\x1b]8;;" + url + "\x1b\\" + label + "\x1b]8;;\x1b\\" } -// Confirm is a yes/no select styled by the active theme. -func Confirm() *huh.Select[bool] { - return huh.NewSelect[bool](). - Options( - huh.NewOption("Yes", true), - huh.NewOption("No", false), - ). - Inline(false) +// Confirm is a yes/no prompt styled by the active theme. It uses huh's built-in +// confirm field, which supports y/n quick entry (Accept: y/Y, Reject: n/N) and +// renders both choices as side-by-side buttons. +func Confirm() *huh.Confirm { + return huh.NewConfirm() }