Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -348,9 +348,10 @@ Compare two sessions side by side with `dispatch compare`:
```sh
dispatch compare 0a1b2c3d 9f8e7d6c
dispatch compare 0a1b2c3d 9f8e7d6c --json
dispatch compare 0a1b2c3d 9f8e7d6c --markdown
```

The output shows metadata differences (summary, branch, turn count), files that appear in only one session, ref differences, and checkpoint title lists. Use `--json` for machine-readable output. Session IDs accept the same prefix shorthand as `open` and `export`.
The output shows metadata differences (summary, branch, turn count), files that appear in only one session, ref differences, and checkpoint title lists. Use `--json` for machine-readable output or `--markdown` to paste the comparison into an issue or PR. Session IDs accept the same prefix shorthand as `open` and `export`.

### Info

Expand Down
106 changes: 90 additions & 16 deletions cmd/dispatch/compare.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,14 +51,24 @@ type metadataDiff struct {
Right string `json:"right"`
}

// runCompare prints a comparison of two sessions as text, or as JSON with
// --json. args is the full argument slice with args[0] == "compare".
// compareOutputFormat selects how runCompare renders the comparison.
type compareOutputFormat string

const (
compareFormatText compareOutputFormat = "text"
compareFormatJSON compareOutputFormat = "json"
compareFormatMarkdown compareOutputFormat = "markdown"
)

// runCompare prints a comparison of two sessions as text, as JSON with --json,
// or as Markdown with --markdown. args is the full argument slice with
// args[0] == "compare".
func runCompare(w io.Writer, args []string) error {
if w == nil {
w = io.Discard
}

leftID, rightID, asJSON, err := parseCompareArgs(args)
leftID, rightID, format, err := parseCompareArgs(args)
if err != nil {
return err
}
Expand All @@ -80,41 +90,59 @@ func runCompare(w io.Writer, args []string) error {
}

cmp := buildComparison(leftDetail, rightDetail)
if asJSON {
switch format {
case compareFormatJSON:
return writeCompareJSON(w, cmp)
case compareFormatMarkdown:
return writeCompareMarkdown(w, cmp)
default:
return writeCompareText(w, cmp)
}
return writeCompareText(w, cmp)
}

// parseCompareArgs extracts the two session IDs and the --json flag from the
// compare subcommand arguments. args[0] is expected to be "compare".
func parseCompareArgs(args []string) (leftID, rightID string, asJSON bool, err error) {
// parseCompareArgs extracts the two session IDs and the output format from the
// compare subcommand arguments. args[0] is expected to be "compare". --json and
// --markdown select a format and cannot be combined.
func parseCompareArgs(args []string) (leftID, rightID string, format compareOutputFormat, err error) {
rest := args
if len(rest) > 0 {
rest = rest[1:] // drop the "compare" token
}

jsonOut := false
markdownOut := false
var positionals []string
for _, arg := range rest {
switch {
case arg == "--json":
asJSON = true
jsonOut = true
case arg == "--markdown":
markdownOut = true
case strings.HasPrefix(arg, "-"):
return "", "", false, fmt.Errorf("unknown flag: %s", arg)
return "", "", "", fmt.Errorf("unknown flag: %s", arg)
default:
positionals = append(positionals, arg)
}
}

if jsonOut && markdownOut {
return "", "", "", errors.New("--json and --markdown cannot be combined")
}
format = compareFormatText
switch {
case jsonOut:
format = compareFormatJSON
case markdownOut:
format = compareFormatMarkdown
}

switch len(positionals) {
case 0:
return "", "", false, errors.New("compare requires two session IDs")
case 1:
return "", "", false, errors.New("compare requires two session IDs")
case 0, 1:
return "", "", "", errors.New("compare requires two session IDs")
case 2:
return positionals[0], positionals[1], asJSON, nil
return positionals[0], positionals[1], format, nil
default:
return "", "", false, fmt.Errorf("compare accepts exactly two session IDs, got %d", len(positionals))
return "", "", "", fmt.Errorf("compare accepts exactly two session IDs, got %d", len(positionals))
}
}

Expand Down Expand Up @@ -280,3 +308,49 @@ func writeCompareList(b *strings.Builder, label string, items []string) {
}
}
}

// writeCompareMarkdown renders the comparison as Markdown so it can be pasted
// into issues, PRs, or reports.
func writeCompareMarkdown(w io.Writer, cmp sessionComparison) error {
var b strings.Builder

b.WriteString("# Session comparison\n\n")
b.WriteString("| Side | Session |\n")
b.WriteString("|---|---|\n")
fmt.Fprintf(&b, "| Left | %s |\n", markdownCell(cmp.Left.ID))
fmt.Fprintf(&b, "| Right | %s |\n", markdownCell(cmp.Right.ID))

b.WriteString("\n## Metadata\n\n")
if len(cmp.MetadataDiffs) == 0 {
b.WriteString("Metadata is identical.\n")
} else {
b.WriteString("| Field | Left | Right |\n")
b.WriteString("|---|---|---|\n")
for _, d := range cmp.MetadataDiffs {
fmt.Fprintf(&b, "| %s | %s | %s |\n", markdownCell(d.Field), markdownCell(d.Left), markdownCell(d.Right))
}
}

writeCompareMarkdownList(&b, "Checkpoint titles (left)", cmp.Left.CheckpointTitles)
writeCompareMarkdownList(&b, "Checkpoint titles (right)", cmp.Right.CheckpointTitles)
writeCompareMarkdownList(&b, "Files only in left", cmp.FilesOnlyLeft)
writeCompareMarkdownList(&b, "Files only in right", cmp.FilesOnlyRight)
writeCompareMarkdownList(&b, "Refs only in left", cmp.RefsOnlyLeft)
writeCompareMarkdownList(&b, "Refs only in right", cmp.RefsOnlyRight)

_, err := io.WriteString(w, b.String())
return err
}

// writeCompareMarkdownList appends a Markdown section with a bulleted list, or
// an italic "(none)" when the slice is empty.
func writeCompareMarkdownList(b *strings.Builder, heading string, items []string) {
fmt.Fprintf(b, "\n## %s\n\n", heading)
if len(items) == 0 {
b.WriteString("_(none)_\n")
return
}
for _, item := range items {
fmt.Fprintf(b, "- %s\n", markdownCell(item))
}
}
76 changes: 68 additions & 8 deletions cmd/dispatch/compare_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,13 +105,16 @@ func TestParseCompareArgs(t *testing.T) {
args []string
wantLeft string
wantRight string
wantJSON bool
wantFmt compareOutputFormat
wantErr bool
}{
{name: "two ids", args: []string{"compare", "a", "b"}, wantLeft: "a", wantRight: "b"},
{name: "two ids with json", args: []string{"compare", "a", "b", "--json"}, wantLeft: "a", wantRight: "b", wantJSON: true},
{name: "json between ids", args: []string{"compare", "a", "--json", "b"}, wantLeft: "a", wantRight: "b", wantJSON: true},
{name: "json before ids", args: []string{"compare", "--json", "a", "b"}, wantLeft: "a", wantRight: "b", wantJSON: true},
{name: "two ids", args: []string{"compare", "a", "b"}, wantLeft: "a", wantRight: "b", wantFmt: compareFormatText},
{name: "two ids with json", args: []string{"compare", "a", "b", "--json"}, wantLeft: "a", wantRight: "b", wantFmt: compareFormatJSON},
{name: "json between ids", args: []string{"compare", "a", "--json", "b"}, wantLeft: "a", wantRight: "b", wantFmt: compareFormatJSON},
{name: "json before ids", args: []string{"compare", "--json", "a", "b"}, wantLeft: "a", wantRight: "b", wantFmt: compareFormatJSON},
{name: "two ids with markdown", args: []string{"compare", "a", "b", "--markdown"}, wantLeft: "a", wantRight: "b", wantFmt: compareFormatMarkdown},
{name: "markdown before ids", args: []string{"compare", "--markdown", "a", "b"}, wantLeft: "a", wantRight: "b", wantFmt: compareFormatMarkdown},
{name: "json and markdown conflict", args: []string{"compare", "a", "b", "--json", "--markdown"}, wantErr: true},
{name: "no args", args: []string{"compare"}, wantErr: true},
{name: "one arg", args: []string{"compare", "a"}, wantErr: true},
{name: "three args", args: []string{"compare", "a", "b", "c"}, wantErr: true},
Expand All @@ -120,7 +123,7 @@ func TestParseCompareArgs(t *testing.T) {

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
leftID, rightID, asJSON, err := parseCompareArgs(tt.args)
leftID, rightID, format, err := parseCompareArgs(tt.args)
if tt.wantErr {
if err == nil {
t.Fatal("expected an error")
Expand All @@ -136,8 +139,8 @@ func TestParseCompareArgs(t *testing.T) {
if rightID != tt.wantRight {
t.Errorf("rightID = %q, want %q", rightID, tt.wantRight)
}
if asJSON != tt.wantJSON {
t.Errorf("asJSON = %v, want %v", asJSON, tt.wantJSON)
if format != tt.wantFmt {
t.Errorf("format = %q, want %q", format, tt.wantFmt)
}
})
}
Expand Down Expand Up @@ -228,6 +231,63 @@ func TestRunCompare_JSON(t *testing.T) {
}
}

// ---------------------------------------------------------------------------
// Markdown output
// ---------------------------------------------------------------------------

func TestRunCompare_Markdown(t *testing.T) {
withCompareDetail(t, compareLoader())

var buf bytes.Buffer
if err := runCompare(&buf, []string{"compare", "ses-left", "ses-right", "--markdown"}); err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := buf.String()

for _, want := range []string{
"# Session comparison",
"| Side | Session |",
"ses-left",
"ses-right",
"## Metadata",
"| Field | Left | Right |",
"| summary |",
"Add auth",
"Fix login",
"## Files only in left",
"- src/auth.go",
"## Files only in right",
"- src/login.go",
"## Refs only in left",
"- commit:abc123",
"## Checkpoint titles (left)",
"- Setup auth",
} {
if !strings.Contains(out, want) {
t.Errorf("markdown output missing %q, got:\n%s", want, out)
}
}
}

func TestRunCompare_MarkdownIdentical(t *testing.T) {
withCompareDetail(t, func(string) (*data.SessionDetail, error) {
return compareSampleLeft(), nil
})

var buf bytes.Buffer
if err := runCompare(&buf, []string{"compare", "ses-left", "ses-left", "--markdown"}); err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := buf.String()

if !strings.Contains(out, "Metadata is identical.") {
t.Errorf("expected identical metadata note, got:\n%s", out)
}
if !strings.Contains(out, "_(none)_") {
t.Errorf("expected empty-list marker for identical sessions, got:\n%s", out)
}
}

// ---------------------------------------------------------------------------
// Missing session IDs
// ---------------------------------------------------------------------------
Expand Down
2 changes: 1 addition & 1 deletion cmd/dispatch/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ Commands:
Print a concise session summary
path <id|--last|--current>
Print a session's working directory; IDs may be aliases or prefixes
compare <a> <b> [--json]
compare <a> <b> [--json|--markdown]
Compare two sessions side by side
tag <id> [flags] Add, remove, set, or list tags on a session
prune [--apply] [--json]
Expand Down
2 changes: 1 addition & 1 deletion cmd/dispatch/man.go
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ var manCommands = []manEntry{
{"export <id> [flags]", "Export a session as Markdown, JSON, HTML, or plain text."},
{"info <id> [--json] [--refs]", "Print a concise session summary."},
{"path <id|--last|--current>", "Print a session's working directory. IDs may be aliases or unique prefixes."},
{"compare <a> <b> [--json]", "Compare two sessions side by side."},
{"compare <a> <b> [--json|--markdown]", "Compare two sessions side by side."},
{"tag <id> [flags]", "Add, remove, set, or list tags on a session."},
{"prune [--apply] [--json]", "Report (or remove) config entries for missing sessions."},
{"watch [--once] [flags]", "Monitor session attention state."},
Expand Down
Loading