Compare commits

...

2 Commits

Author SHA1 Message Date
Bjorn Robertsson 0284c50fa4 docs: add CLI reference for template versions diff 2026-02-20 11:47:08 +00:00
Bjorn Robertsson 98a2534141 feat: add template versions diff command
Adds `coder templates versions diff` command to compare any two template
versions, similar to GitHub's compare functionality.

Features:
- Compare any two versions with --from and --to flags
- Interactive version selection when flags not provided
- Colorized unified diff output (additions/deletions/hunks)
- Defaults --to to active version when only --from specified

Usage:
  coder templates versions diff my-template --from v1 --to v2
  coder templates versions diff my-template --from v1
  coder templates versions diff my-template  # interactive

Fixes #22213
2026-02-20 11:47:08 +00:00
8 changed files with 406 additions and 1 deletions
+1
View File
@@ -30,6 +30,7 @@ func (r *RootCmd) templateVersions() *serpent.Command {
},
Children: []*serpent.Command{
r.templateVersionsList(),
r.templateVersionsDiff(),
r.archiveTemplateVersion(),
r.unarchiveTemplateVersion(),
r.templateVersionsPromote(),
+313
View File
@@ -0,0 +1,313 @@
package cli
import (
"bytes"
"context"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"github.com/hexops/gotextdiff"
"github.com/hexops/gotextdiff/myers"
"github.com/hexops/gotextdiff/span"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/provisionersdk"
"github.com/coder/pretty"
"github.com/coder/serpent"
)
// templateVersionsDiff compares two template versions.
// Initial implementation generated with assistance from Mux (mux.coder.com).
func (r *RootCmd) templateVersionsDiff() *serpent.Command {
var (
versionFrom string
versionTo string
orgContext = NewOrganizationContext()
)
cmd := &serpent.Command{
Use: "diff <template>",
Short: "Compare two versions of a template",
Long: FormatExamples(
Example{
Description: "Compare two specific versions of a template",
Command: "coder templates versions diff my-template --from v1 --to v2",
},
Example{
Description: "Compare a version against the active version",
Command: "coder templates versions diff my-template --from v1",
},
Example{
Description: "Interactive: select versions to compare",
Command: "coder templates versions diff my-template",
},
),
Middleware: serpent.Chain(
serpent.RequireNArgs(1),
),
Handler: func(inv *serpent.Invocation) error {
ctx := inv.Context()
templateName := inv.Args[0]
client, err := r.InitClient(inv)
if err != nil {
return err
}
organization, err := orgContext.Selected(inv, client)
if err != nil {
return xerrors.Errorf("get current organization: %w", err)
}
template, err := client.TemplateByName(ctx, organization.ID, templateName)
if err != nil {
return xerrors.Errorf("get template by name: %w", err)
}
// Get all versions
versions, err := client.TemplateVersionsByTemplate(ctx, codersdk.TemplateVersionsByTemplateRequest{
TemplateID: template.ID,
})
if err != nil {
return xerrors.Errorf("get template versions: %w", err)
}
if len(versions) < 2 {
return xerrors.Errorf("template %q has fewer than 2 versions, nothing to compare", templateName)
}
// Sort versions by creation date (newest first)
sort.SliceStable(versions, func(i, j int) bool {
return versions[i].CreatedAt.After(versions[j].CreatedAt)
})
// Build version names list for interactive selection
var versionNames []string
for _, v := range versions {
label := v.Name
if v.ID == template.ActiveVersionID {
label += " (active)"
}
versionNames = append(versionNames, label)
}
// Resolve "from" version
if versionFrom == "" {
// Interactive selection
selected, err := cliui.Select(inv, cliui.SelectOptions{
Options: versionNames,
Message: "Select the first version (older/base):",
})
if err != nil {
return err
}
versionFrom = strings.TrimSuffix(selected, " (active)")
}
// Resolve "to" version (defaults to active)
if versionTo == "" {
// Interactive selection or default to active
if inv.Stdin == os.Stdin {
selected, err := cliui.Select(inv, cliui.SelectOptions{
Options: versionNames,
Message: "Select the second version (newer/target):",
})
if err != nil {
return err
}
versionTo = strings.TrimSuffix(selected, " (active)")
} else {
// Non-interactive: default to active version
versionTo = "active"
}
}
// Fetch full version details using TemplateVersionByName (like templatepull does)
var fromVersion, toVersion codersdk.TemplateVersion
fromVersion, err = client.TemplateVersionByName(ctx, template.ID, versionFrom)
if err != nil {
return xerrors.Errorf("get version %q: %w", versionFrom, err)
}
if versionTo == "active" {
toVersion, err = client.TemplateVersion(ctx, template.ActiveVersionID)
} else {
toVersion, err = client.TemplateVersionByName(ctx, template.ID, versionTo)
}
if err != nil {
return xerrors.Errorf("get version %q: %w", versionTo, err)
}
if fromVersion.ID == toVersion.ID {
cliui.Info(inv.Stderr, "Both versions are the same, no diff to show.")
return nil
}
cliui.Info(inv.Stderr, fmt.Sprintf("Comparing %s → %s", cliui.Bold(fromVersion.Name), cliui.Bold(toVersion.Name)))
// Download both versions
fromFiles, err := downloadAndExtractVersion(ctx, client, fromVersion)
if err != nil {
return xerrors.Errorf("download version %q: %w", fromVersion.Name, err)
}
toFiles, err := downloadAndExtractVersion(ctx, client, toVersion)
if err != nil {
return xerrors.Errorf("download version %q: %w", toVersion.Name, err)
}
// Generate diff
diff := generateDiff(fromVersion.Name, toVersion.Name, fromFiles, toFiles)
if diff == "" {
cliui.Info(inv.Stderr, "No differences found between versions.")
return nil
}
// Output colorized diff
_, _ = fmt.Fprintln(inv.Stdout, colorizeDiff(diff))
return nil
},
}
cmd.Options = serpent.OptionSet{
{
Description: "The base version to compare from.",
Flag: "from",
Value: serpent.StringOf(&versionFrom),
},
{
Description: "The target version to compare to (defaults to active version).",
Flag: "to",
Value: serpent.StringOf(&versionTo),
},
}
orgContext.AttachOptions(cmd)
return cmd
}
// downloadAndExtractVersion downloads a template version and extracts its files into memory
func downloadAndExtractVersion(ctx context.Context, client *codersdk.Client, version codersdk.TemplateVersion) (map[string]string, error) {
raw, ctype, err := client.DownloadWithFormat(ctx, version.Job.FileID, "")
if err != nil {
return nil, xerrors.Errorf("download: %w", err)
}
if ctype != codersdk.ContentTypeTar {
return nil, xerrors.Errorf("unexpected content type %q", ctype)
}
// Extract to temp directory
tmpDir, err := os.MkdirTemp("", "coder-template-diff-*")
if err != nil {
return nil, xerrors.Errorf("create temp dir: %w", err)
}
defer os.RemoveAll(tmpDir)
err = provisionersdk.Untar(tmpDir, bytes.NewReader(raw))
if err != nil {
return nil, xerrors.Errorf("untar: %w", err)
}
// Read all files into memory
files := make(map[string]string)
err = filepath.Walk(tmpDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() {
return nil
}
relPath, err := filepath.Rel(tmpDir, path)
if err != nil {
return err
}
content, err := os.ReadFile(path)
if err != nil {
return err
}
files[relPath] = string(content)
return nil
})
if err != nil {
return nil, xerrors.Errorf("walk files: %w", err)
}
return files, nil
}
// generateDiff creates a unified diff between two sets of files
func generateDiff(fromName, toName string, fromFiles, toFiles map[string]string) string {
// Collect all unique file paths
allPaths := make(map[string]struct{})
for p := range fromFiles {
allPaths[p] = struct{}{}
}
for p := range toFiles {
allPaths[p] = struct{}{}
}
// Sort paths for deterministic output
var paths []string
for p := range allPaths {
paths = append(paths, p)
}
sort.Strings(paths)
var result strings.Builder
for _, path := range paths {
fromContent := fromFiles[path]
toContent := toFiles[path]
if fromContent == toContent {
continue
}
fromLabel := fmt.Sprintf("a/%s (%s)", path, fromName)
toLabel := fmt.Sprintf("b/%s (%s)", path, toName)
edits := myers.ComputeEdits(span.URIFromPath(path), fromContent, toContent)
unified := gotextdiff.ToUnified(fromLabel, toLabel, fromContent, edits)
if len(unified.Hunks) > 0 {
_, _ = result.WriteString(fmt.Sprint(unified))
_, _ = result.WriteString("\n")
}
}
return result.String()
}
// colorizeDiff adds ANSI colors to diff output
func colorizeDiff(diff string) string {
var result strings.Builder
lines := strings.Split(diff, "\n")
for _, line := range lines {
switch {
case strings.HasPrefix(line, "+++") || strings.HasPrefix(line, "---"):
_, _ = result.WriteString(pretty.Sprint(cliui.DefaultStyles.Code, line))
case strings.HasPrefix(line, "+"):
_, _ = result.WriteString(pretty.Sprint(cliui.DefaultStyles.Keyword, line))
case strings.HasPrefix(line, "-"):
_, _ = result.WriteString(pretty.Sprint(cliui.DefaultStyles.Error, line))
case strings.HasPrefix(line, "@@"):
_, _ = result.WriteString(pretty.Sprint(cliui.DefaultStyles.Placeholder, line))
default:
_, _ = result.WriteString(line)
}
_, _ = result.WriteString("\n")
}
return strings.TrimSuffix(result.String(), "\n")
}
+1
View File
@@ -13,6 +13,7 @@ USAGE:
SUBCOMMANDS:
archive Archive a template version(s).
diff Compare two versions of a template
list List all the versions of the specified template
promote Promote a template version to active.
unarchive Unarchive a template version(s).
@@ -0,0 +1,31 @@
coder v0.0.0-devel
USAGE:
coder templates versions diff [flags] <template>
Compare two versions of a template
- Compare two specific versions of a template:
$ coder templates versions diff my-template --from v1 --to v2
- Compare a version against the active version:
$ coder templates versions diff my-template --from v1
- Interactive: select versions to compare:
$ coder templates versions diff my-template
OPTIONS:
-O, --org string, $CODER_ORGANIZATION
Select which organization (uuid or name) to use.
--from string
The base version to compare from.
--to string
The target version to compare to (defaults to active version).
———
Run `coder --help` for a list of global options.
+5
View File
@@ -2116,6 +2116,11 @@
"description": "Archive a template version(s).",
"path": "reference/cli/templates_versions_archive.md"
},
{
"title": "templates versions diff",
"description": "Compare two versions of a template",
"path": "reference/cli/templates_versions_diff.md"
},
{
"title": "templates versions list",
"description": "List all the versions of the specified template",
+1
View File
@@ -26,6 +26,7 @@ coder templates versions
| Name | Purpose |
|-------------------------------------------------------------|-------------------------------------------------|
| [<code>list</code>](./templates_versions_list.md) | List all the versions of the specified template |
| [<code>diff</code>](./templates_versions_diff.md) | Compare two versions of a template |
| [<code>archive</code>](./templates_versions_archive.md) | Archive a template version(s). |
| [<code>unarchive</code>](./templates_versions_unarchive.md) | Unarchive a template version(s). |
| [<code>promote</code>](./templates_versions_promote.md) | Promote a template version to active. |
+53
View File
@@ -0,0 +1,53 @@
<!-- DO NOT EDIT | GENERATED CONTENT -->
# templates versions diff
Compare two versions of a template
## Usage
```console
coder templates versions diff [flags] <template>
```
## Description
```console
- Compare two specific versions of a template:
$ coder templates versions diff my-template --from v1 --to v2
- Compare a version against the active version:
$ coder templates versions diff my-template --from v1
- Interactive: select versions to compare:
$ coder templates versions diff my-template
```
## Options
### --from
| | |
|------|---------------------|
| Type | <code>string</code> |
The base version to compare from.
### --to
| | |
|------|---------------------|
| Type | <code>string</code> |
The target version to compare to (defaults to active version).
### -O, --org
| | |
|-------------|----------------------------------|
| Type | <code>string</code> |
| Environment | <code>$CODER_ORGANIZATION</code> |
Select which organization (uuid or name) to use.
+1 -1
View File
@@ -482,6 +482,7 @@ require (
github.com/elazarl/goproxy v1.8.0
github.com/fsnotify/fsnotify v1.9.0
github.com/go-git/go-git/v5 v5.16.5
github.com/hexops/gotextdiff v1.0.3
github.com/icholy/replace v0.6.0
github.com/mark3labs/mcp-go v0.38.0
gonum.org/v1/gonum v0.17.0
@@ -536,7 +537,6 @@ require (
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect
github.com/hashicorp/go-getter v1.7.9 // indirect
github.com/hashicorp/go-safetemp v1.0.0 // indirect
github.com/hexops/gotextdiff v1.0.3 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/invopop/jsonschema v0.13.0 // indirect
github.com/jackmordaunt/icns/v3 v3.0.1 // indirect