You are implementing Task 9 of the birdseye CLI implementation plan.

## Working Environment

- **Worktree path:** `/Users/steve.briggs/projects/operations/.claude/worktrees/birdseye`
- **Git branch:** `worktree-birdseye`
- Recent commits include Phase A + Phase B (latest: `b7ab5b6`).
- Use absolute paths or `cd` into the worktree.
- **NEVER push.** **NEVER use `git add .` / `git add -A`.**

## Context

This task creates a NEW package `internal/diff` in `apps/birdseye/`. It contains the pure diff functions that take two `archive.Module` values and produce typed diff results. Subsequent tasks add the plain CLI rendering, TUI rendering, and semantic HCL diff.

The textual diff uses `github.com/hexops/gotextdiff` (same library `gopls` uses). The plan provides the full implementation; your job is mostly mechanical transcription with TDD.

Strict TDD: write failing tests, run, then implement.

## Task Description (Verbatim)

### Task 9: `internal/diff` — types and textual diff

**Files:**
- Create: `apps/birdseye/internal/diff/types.go`
- Create: `apps/birdseye/internal/diff/textual.go`
- Create: `apps/birdseye/internal/diff/textual_test.go`

- [ ] **Step 1: Write failing test** at `apps/birdseye/internal/diff/textual_test.go`:

```go
package diff

import (
	"strings"
	"testing"

	"github.com/immuta/operations/apps/birdseye/internal/archive"
)

func mkArchive(files map[string]string) archive.Module {
	out := archive.Module{Files: make(map[string][]byte, len(files))}
	for k, v := range files {
		out.Files[k] = []byte(v)
	}
	return out
}

func findFile(t *testing.T, ds []FileDiff, path string) FileDiff {
	t.Helper()
	for _, d := range ds {
		if d.Path == path {
			return d
		}
	}
	t.Fatalf("no FileDiff for %s; got %d entries", path, len(ds))
	return FileDiff{}
}

func TestTextualAdded(t *testing.T) {
	a := mkArchive(nil)
	b := mkArchive(map[string]string{"new.tf": "x\n"})
	ds := Textual(a, b)
	d := findFile(t, ds, "new.tf")
	if d.Status != StatusAdded {
		t.Errorf("status = %v, want Added", d.Status)
	}
	if d.Adds != 1 || d.Dels != 0 {
		t.Errorf("adds/dels = %d/%d", d.Adds, d.Dels)
	}
}

func TestTextualRemoved(t *testing.T) {
	a := mkArchive(map[string]string{"old.tf": "x\n"})
	b := mkArchive(nil)
	ds := Textual(a, b)
	d := findFile(t, ds, "old.tf")
	if d.Status != StatusRemoved {
		t.Errorf("status = %v, want Removed", d.Status)
	}
	if d.Adds != 0 || d.Dels != 1 {
		t.Errorf("adds/dels = %d/%d", d.Adds, d.Dels)
	}
}

func TestTextualModified(t *testing.T) {
	a := mkArchive(map[string]string{"m.tf": "alpha\nbeta\n"})
	b := mkArchive(map[string]string{"m.tf": "alpha\nGAMMA\n"})
	ds := Textual(a, b)
	d := findFile(t, ds, "m.tf")
	if d.Status != StatusModified {
		t.Errorf("status = %v, want Modified", d.Status)
	}
	if !strings.Contains(d.Unified, "-beta") || !strings.Contains(d.Unified, "+GAMMA") {
		t.Errorf("unified missing expected lines:\n%s", d.Unified)
	}
}

func TestTextualIdenticalReturnsEmpty(t *testing.T) {
	a := mkArchive(map[string]string{"m.tf": "x\n"})
	b := mkArchive(map[string]string{"m.tf": "x\n"})
	if got := Textual(a, b); len(got) != 0 {
		t.Errorf("expected 0 diffs, got %d", len(got))
	}
}

func TestTextualSortedByPath(t *testing.T) {
	a := mkArchive(map[string]string{"z.tf": "a\n", "a.tf": "a\n"})
	b := mkArchive(map[string]string{"z.tf": "b\n", "a.tf": "b\n"})
	ds := Textual(a, b)
	if len(ds) != 2 {
		t.Fatalf("got %d diffs, want 2", len(ds))
	}
	if ds[0].Path != "a.tf" || ds[1].Path != "z.tf" {
		t.Errorf("expected sorted by path; got %s, %s", ds[0].Path, ds[1].Path)
	}
}
```

- [ ] **Step 2: Run, expect FAIL** (compile errors)

Run: `cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye && go test ./internal/diff/...`

- [ ] **Step 3: Implement `apps/birdseye/internal/diff/types.go`**

```go
// Package diff produces FileDiff entries and optional SemanticChange
// entries from a pair of extracted Modules. All functions are pure: no
// I/O, no globals.
package diff

// FileStatus describes what happened to a file between the two versions.
type FileStatus int

const (
	StatusModified FileStatus = iota
	StatusAdded
	StatusRemoved
)

func (s FileStatus) String() string {
	switch s {
	case StatusAdded:
		return "added"
	case StatusRemoved:
		return "removed"
	default:
		return "modified"
	}
}

// FileDiff is a per-file diff result. Unified holds the rendered diff
// hunks; Adds/Dels are the count of added/removed lines for summary
// rendering.
type FileDiff struct {
	Path    string
	Status  FileStatus
	Unified string
	Adds    int
	Dels    int
}

// SemanticChange is a single HCL-aware change. Kind is one of "resource",
// "variable", "output", "provider", "locals", "data", or "module".
type SemanticChange struct {
	Kind   string
	Name   string
	Status FileStatus
	Detail string
}

// Result is what cmd/diff renders. Semantic is nil unless --semantic was
// requested.
type Result struct {
	Files    []FileDiff
	Semantic []SemanticChange
}
```

- [ ] **Step 4: Implement `apps/birdseye/internal/diff/textual.go`**

```go
package diff

import (
	"sort"
	"strings"

	"github.com/hexops/gotextdiff"
	"github.com/hexops/gotextdiff/myers"
	"github.com/hexops/gotextdiff/span"

	"github.com/immuta/operations/apps/birdseye/internal/archive"
)

// Textual returns a FileDiff per file that differs between a and b,
// sorted by path. Files identical in both archives are not included.
func Textual(a, b archive.Module) []FileDiff {
	all := make(map[string]struct{})
	for k := range a.Files {
		all[k] = struct{}{}
	}
	for k := range b.Files {
		all[k] = struct{}{}
	}
	paths := make([]string, 0, len(all))
	for k := range all {
		paths = append(paths, k)
	}
	sort.Strings(paths)

	out := make([]FileDiff, 0, len(paths))
	for _, p := range paths {
		ab, aok := a.Files[p]
		bb, bok := b.Files[p]
		switch {
		case aok && bok:
			if string(ab) == string(bb) {
				continue
			}
			out = append(out, modifiedDiff(p, string(ab), string(bb)))
		case !aok && bok:
			out = append(out, addedDiff(p, string(bb)))
		case aok && !bok:
			out = append(out, removedDiff(p, string(ab)))
		}
	}
	return out
}

func modifiedDiff(path, oldText, newText string) FileDiff {
	edits := myers.ComputeEdits(span.URIFromPath(path), oldText, newText)
	unified := gotextdiff.ToUnified(path, path, oldText, edits)
	u := unified.String()
	adds, dels := countLines(u)
	return FileDiff{Path: path, Status: StatusModified, Unified: u, Adds: adds, Dels: dels}
}

func addedDiff(path, newText string) FileDiff {
	return FileDiff{
		Path:    path,
		Status:  StatusAdded,
		Unified: "+++ " + path + "\n" + prefixLines(newText, "+"),
		Adds:    countNonEmptyLines(newText),
	}
}

func removedDiff(path, oldText string) FileDiff {
	return FileDiff{
		Path:    path,
		Status:  StatusRemoved,
		Unified: "--- " + path + "\n" + prefixLines(oldText, "-"),
		Dels:    countNonEmptyLines(oldText),
	}
}

func prefixLines(s, prefix string) string {
	if s == "" {
		return ""
	}
	lines := strings.Split(strings.TrimRight(s, "\n"), "\n")
	var sb strings.Builder
	sb.Grow(len(s) + len(lines)*len(prefix))
	for _, l := range lines {
		sb.WriteString(prefix)
		sb.WriteString(l)
		sb.WriteByte('\n')
	}
	return sb.String()
}

func countNonEmptyLines(s string) int {
	if s == "" {
		return 0
	}
	n := strings.Count(s, "\n")
	if !strings.HasSuffix(s, "\n") {
		n++
	}
	return n
}

func countLines(unified string) (adds, dels int) {
	for _, line := range strings.Split(unified, "\n") {
		if len(line) == 0 {
			continue
		}
		switch line[0] {
		case '+':
			if !strings.HasPrefix(line, "+++") {
				adds++
			}
		case '-':
			if !strings.HasPrefix(line, "---") {
				dels++
			}
		}
	}
	return adds, dels
}
```

- [ ] **Step 5: Run, expect PASS**

Run: `cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye && go test -race ./internal/diff/... -v`
Expected: All 5 tests PASS.

- [ ] **Step 6: Commit**

```bash
cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye && git add apps/birdseye/internal/diff/ apps/birdseye/go.mod apps/birdseye/go.sum && git commit -m "feat(birdseye): textual unified diff between extracted modules"
```

Drop go.mod/go.sum from `git add` if neither was modified.

## Notes & Style

- Strict TDD.
- `github.com/hexops/gotextdiff` should already be in go.mod from Task 1's initial dependency list. `go mod tidy` is your friend if not.
- The plan deliberately splits added/removed/modified into helper functions for clarity. Keep them.
- The `prefixLines` and `countLines` helpers are private to this file. Don't export.
- Tests do not import gotextdiff — they only verify behavior via the public `FileDiff` shape.

## Code Organization

Two production files: `types.go` (data) + `textual.go` (logic). Test file: `textual_test.go`. Three files total.

## Self-Review Checklist

- [ ] All 5 textual tests pass (-race)
- [ ] `go vet ./...` clean
- [ ] go.mod/go.sum reflect any new direct dep on `gotextdiff` (it should already be direct from Task 1's plan, but verify)
- [ ] `StatusModified` is the iota=0 value (the plan puts it first)
- [ ] `FileStatus.String()` returns "added", "removed", "modified"
- [ ] Identical archives → empty result (verified by `TestTextualIdenticalReturnsEmpty`)
- [ ] Output is path-sorted (verified by `TestTextualSortedByPath`)
- [ ] Commit message exactly: `feat(birdseye): textual unified diff between extracted modules`

## Report Format

- **Status:** DONE | DONE_WITH_CONCERNS | BLOCKED | NEEDS_CONTEXT
- Files created
- Test count + pass status
- go.mod/go.sum changes (if any)
- Commit SHA
- Anything unexpected


- migrate-flux-app: Use when migrating a Flux CD application from the infrastructure/kubernetes/application-deployments repo to the application-deployment repo. Covers copying app folders, updating kustomizations, committing, pushing, and creating merge requests.
- app-infra-generate: Generate an application-infrastructure Terraform module from a YAML spec produced by app-infra-spec. Reads pattern files and a versions manifest to produce main.tf, variables.tf, outputs.tf, overlay guidance, and a triage list.
- app-infra-spec: Structured questionnaire to gather requirements for an application-infrastructure Terraform module. Produces a YAML spec for the app-infra-generate skill.
- obsidian-load: Load context from Obsidian notes in the `Claude/` directory into the current conversation.
- obsidian-save: Save current conversation context to an Obsidian note under the `Claude/` directory.
- mind:search: Search memories for specific content or patterns
- mind:stats: Show memory statistics and storage information
- mind:recent: Show recent memories and activity timeline
- mind:ask: Ask questions about memories and get context-aware answers
- superpowers:using-git-worktrees: Use when starting feature work that needs isolation from current workspace or before executing implementation plans - ensures an isolated workspace exists via native tools or git worktree fallback
- superpowers:receiving-code-review: Use when receiving code review feedback, before implementing suggestions, especially if feedback seems unclear or technically questionable - requires technical rigor and verification, not performative agreement or blind implementation
- superpowers:finishing-a-development-branch: Use when implementation is complete, all tests pass, and you need to decide how to integrate the work - guides completion of development work by presenting structured options for merge, PR, or cleanup
- superpowers:dispatching-parallel-agents: Use when facing 2+ independent tasks that can be worked on without shared state or sequential dependencies
- superpowers:writing-plans: Use when you have a spec or requirements for a multi-step task, before touching code
- superpowers:using-superpowers: Use when starting any conversation - establishes how to find and use skills, requiring Skill tool invocation before ANY response including clarifying questions
- superpowers:writing-skills: Use when creating new skills, editing existing skills, or verifying skills work before deployment
- superpowers:requesting-code-review: Use when completing tasks, implementing major features, or before merging to verify work meets requirements
- superpowers:executing-plans: Use when you have a written implementation plan to execute in a separate session with review checkpoints
- superpowers:verification-before-completion: Use when about to claim work is complete, fixed, or passing, before committing or creating PRs - requires running verification commands and confirming output before making any success claims; evidence before assertions always
- superpowers:brainstorming: You MUST use this before any creative work - creating features, building components, adding functionality, or modifying behavior. Explores user intent, requirements and design before implementation.
- superpowers:systematic-debugging: Use when encountering any bug, test failure, or unexpected behavior, before proposing fixes
- superpowers:test-driven-development: Use when implementing any feature or bugfix, before writing implementation code
- superpowers:subagent-driven-development: Use when executing implementation plans with independent tasks in the current session
- mind:memory: Claude Mind - Search and manage Claude's persistent memory stored in a single portable .mv2 file
- mind:mind: Claude Mind - Search and manage Claude's persistent memory stored in a single portable .mv2 file
- update-config: Use this skill to configure the Claude Code harness via settings.json. Automated behaviors ("from now on when X", "each time X", "whenever X", "before/after X") require hooks configured in settings.json - the harness executes these, not Claude, so memory/preferences cannot fulfill them. Also use for: permissions ("allow X", "add permission", "move permission to"), env vars ("set X=Y"), hook troubleshooting, or any changes to settings.json/settings.local.json files. Examples: "allow npm commands", "add bq permission to global settings", "move permission to user settings", "set DEBUG=true", "when claude stops show X". For simple settings like theme/model, suggest the /config command.
- keybindings-help: Use when the user wants to customize keyboard shortcuts, rebind keys, add chord bindings, or modify ~/.claude/keybindings.json. Examples: "rebind ctrl+s", "add a chord shortcut", "change the submit key", "customize keybindings".
- simplify: Review changed code for reuse, quality, and efficiency, then fix any issues found.
- fewer-permission-prompts: Scan your transcripts for common read-only Bash and MCP tool calls, then add a prioritized allowlist to project .claude/settings.json to reduce permission prompts.
- loop: Run a prompt or slash command on a recurring interval (e.g. /loop 5m /foo). Omit the interval to let the model self-pace. - When the user wants to set up a recurring task, poll for status, or run something repeatedly on an interval (e.g. "check the deploy every 5 minutes", "keep running /babysit-prs"). Do NOT invoke for one-off tasks.
- schedule: Create, update, list, or run scheduled remote agents (routines) that execute on a cron schedule. - When the user wants to schedule a recurring remote agent, set up automated tasks, create a cron job for Claude Code, or manage their scheduled agents/routines. Also use when the user wants a one-time scheduled run ("run this once at 3pm", "remind me to check X tomorrow").
- claude-api: Build, debug, and optimize Claude API / Anthropic SDK apps. Apps built with this skill should include prompt caching. Also handles migrating existing Claude API code between Claude model versions (4.5 → 4.6, 4.6 → 4.7, retired-model replacements).
TRIGGER when: code imports `anthropic`/`@anthropic-ai/sdk`; user asks for the Claude API, Anthropic SDK, or Managed Agents; user adds/modifies/tunes a Claude feature (caching, thinking, compaction, tool use, batch, files, citations, memory) or model (Opus/Sonnet/Haiku) in a file; questions about prompt caching / cache hit rate in an Anthropic SDK project.
SKIP: file imports `openai`/other-provider SDK, filename like `*-openai.py`/`*-generic.py`, provider-neutral code, general programming/ML.
- init: Initialize a new CLAUDE.md file with codebase documentation
- review: Review a pull request
- security-review: Complete a security review of the pending changes on the current branch


I'll implement Task 9 following strict TDD. Let me start by examining the current state of the repository.


Bash

List birdseye app directory


Check go.mod for existing dependencies


drwxr-xr-x steve.briggs staff 256 B  Tue May 12 18:19:50 2026 ..
.rw-r--r-- steve.briggs staff 226 B  Tue May 12 18:19:59 2026 Makefile
.rw-r--r-- steve.briggs staff   6 B  Tue May 12 18:20:01 2026 .gitignore
.rw-r--r-- steve.briggs staff 602 B  Tue May 12 18:20:06 2026 main.go
drwxr-xr-x steve.briggs staff 192 B  Tue May 12 23:04:43 2026 cmd
drwxr-xr-x steve.briggs staff 288 B  Tue May 12 23:12:50 2026 .
drwxr-xr-x steve.briggs staff 128 B  Tue May 12 23:29:48 2026 internal
.rw-r--r-- steve.briggs staff 1.0 KB Tue May 12 23:37:37 2026 go.sum
.rw-r--r-- steve.briggs staff 466 B  Tue May 12 23:37:37 2026 go.mod


module github.com/immuta/operations/apps/birdseye

go 1.25.5

require (
	github.com/immuta/operations/services/crow v0.0.0-20260512210412-d8eb33139c1a
	github.com/spf13/cobra v1.9.1
	golang.org/x/sync v0.20.0
)

require (
	github.com/inconshreveable/mousetrap v1.1.0 // indirect
	github.com/spf13/pflag v1.0.6 // indirect
)

replace github.com/immuta/operations/services/crow => ../../services/crow

replace github.com/immuta/operations/libs/clog => ../../libs/clog


`gotextdiff` is not in go.mod yet. Let me check the internal directory and then proceed with TDD.


List internal directory


drwxr-xr-x steve.briggs staff 288 B Tue May 12 23:12:50 2026 ..
drwxr-xr-x steve.briggs staff 128 B Tue May 12 23:28:06 2026 archive
drwxr-xr-x steve.briggs staff 128 B Tue May 12 23:29:48 2026 .
drwxr-xr-x steve.briggs staff 192 B Tue May 12 23:37:34 2026 registry


ls /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye/internal/archive/ 2>/dev/null && head -30 /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye/internal/archive/*.go 2>/dev/null | head -60

Check archive package structure


.rw-r--r-- steve.briggs staff 2.7 KB Tue May 12 23:25:49 2026 extract.go
.rw-r--r-- steve.briggs staff 3.7 KB Tue May 12 23:28:06 2026 extract_test.go
drwxr-xr-x steve.briggs staff 128 B  Tue May 12 23:28:06 2026 .
drwxr-xr-x steve.briggs staff 128 B  Tue May 12 23:29:48 2026 ..
==> /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye/internal/archive/extract_test.go <==
package archive

import (
	"archive/tar"
	"bytes"
	"compress/gzip"
	"errors"
	"testing"
)

// buildTarGz produces a gzipped tar with the given files in memory.
// Used by archive, diff, and registry tests.
func buildTarGz(t *testing.T, files map[string]string) []byte {
	t.Helper()
	var buf bytes.Buffer
	gz := gzip.NewWriter(&buf)
	tw := tar.NewWriter(gz)
	for name, body := range files {
		hdr := &tar.Header{
			Name: name,
			Mode: 0o644,
			Size: int64(len(body)),
		}
		if err := tw.WriteHeader(hdr); err != nil {
			t.Fatalf("WriteHeader %s: %v", name, err)
		}
		if _, err := tw.Write([]byte(body)); err != nil {
			t.Fatalf("Write %s: %v", name, err)
		}
	}

==> /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye/internal/archive/extract.go <==
// Package archive extracts gzipped tar archives from Crow into an
// in-memory file map. Diff and TUI layers consume the resulting Module
// type and never touch the network themselves.
package archive

import (
	"archive/tar"
	"bytes"
	"compress/gzip"
	"errors"
	"fmt"
	"io"
)

// Module is the in-memory representation of an extracted module tarball.
// Keys are forward-slash relative paths. Symlinks are not stored.
type Module struct {
	Files map[string][]byte
}

var (
	ErrTooLarge   = errors.New("archive exceeds size cap")
	ErrUnsafePath = errors.New("archive contains unsafe path")
)

// Extract decompresses and untars tarball into a Module. If the
// cumulative uncompressed size exceeds maxBytes, Extract returns


Good. I can see the `archive.Module` type. Now let me create the diff directory and write the failing test first (Step 1).


Create the diff package directory


(Bash completed with no output)


Write

package diff

import (
	"strings"
	"testing"

	"github.com/immuta/operations/apps/birdseye/internal/archive"
)

func mkArchive(files map[string]string) archive.Module {
	out := archive.Module{Files: make(map[string][]byte, len(files))}
	for k, v := range files {
		out.Files[k] = []byte(v)
	}
	return out
}

func findFile(t *testing.T, ds []FileDiff, path string) FileDiff {
	t.Helper()
	for _, d := range ds {
		if d.Path == path {
			return d
		}
	}
	t.Fatalf("no FileDiff for %s; got %d entries", path, len(ds))
	return FileDiff{}
}

func TestTextualAdded(t *testing.T) {
	a := mkArchive(nil)
	b := mkArchive(map[string]string{"new.tf": "x\n"})
	ds := Textual(a, b)
	d := findFile(t, ds, "new.tf")
	if d.Status != StatusAdded {
		t.Errorf("status = %v, want Added", d.Status)
	}
	if d.Adds != 1 || d.Dels != 0 {
		t.Errorf("adds/dels = %d/%d", d.Adds, d.Dels)
	}
}

func TestTextualRemoved(t *testing.T) {
	a := mkArchive(map[string]string{"old.tf": "x\n"})
	b := mkArchive(nil)
	ds := Textual(a, b)
	d := findFile(t, ds, "old.tf")
	if d.Status != StatusRemoved {
		t.Errorf("status = %v, want Removed", d.Status)
	}
	if d.Adds != 0 || d.Dels != 1 {
		t.Errorf("adds/dels = %d/%d", d.Adds, d.Dels)
	}
}

func TestTextualModified(t *testing.T) {
	a := mkArchive(map[string]string{"m.tf": "alpha\nbeta\n"})
	b := mkArchive(map[string]string{"m.tf": "alpha\nGAMMA\n"})
	ds := Textual(a, b)
	d := findFile(t, ds, "m.tf")
	if d.Status != StatusModified {
		t.Errorf("status = %v, want Modified", d.Status)
	}
	if !strings.Contains(d.Unified, "-beta") || !strings.Contains(d.Unified, "+GAMMA") {
		t.Errorf("unified missing expected lines:\n%s", d.Unified)
	}
}

func TestTextualIdenticalReturnsEmpty(t *testing.T) {
	a := mkArchive(map[string]string{"m.tf": "x\n"})
	b := mkArchive(map[string]string{"m.tf": "x\n"})
	if got := Textual(a, b); len(got) != 0 {
		t.Errorf("expected 0 diffs, got %d", len(got))
	}
}

func TestTextualSortedByPath(t *testing.T) {
	a := mkArchive(map[string]string{"z.tf": "a\n", "a.tf": "a\n"})
	b := mkArchive(map[string]string{"z.tf": "b\n", "a.tf": "b\n"})
	ds := Textual(a, b)
	if len(ds) != 2 {
		t.Fatalf("got %d diffs, want 2", len(ds))
	}
	if ds[0].Path != "a.tf" || ds[1].Path != "z.tf" {
		t.Errorf("expected sorted by path; got %s, %s", ds[0].Path, ds[1].Path)
	}
}


File created successfully at: /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye/internal/diff/textual_test.go (file state is current in your context — no need to Read it back)


**Step 2: Run, expect FAIL (compile errors)**


Bash

cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye && go test ./internal/diff/... 2>&1

Run tests expecting compile failures


Error: Exit code 1
# github.com/immuta/operations/apps/birdseye/internal/diff [github.com/immuta/operations/apps/birdseye/internal/diff.test]
internal/diff/textual_test.go:18:34: undefined: FileDiff
internal/diff/textual_test.go:26:9: undefined: FileDiff
internal/diff/textual_test.go:32:8: undefined: Textual
internal/diff/textual_test.go:34:17: undefined: StatusAdded
internal/diff/textual_test.go:45:8: undefined: Textual
internal/diff/textual_test.go:47:17: undefined: StatusRemoved
internal/diff/textual_test.go:58:8: undefined: Textual
internal/diff/textual_test.go:60:17: undefined: StatusModified
internal/diff/textual_test.go:71:12: undefined: Textual
internal/diff/textual_test.go:79:8: undefined: Textual
internal/diff/textual_test.go:79:8: too many errors
FAIL	github.com/immuta/operations/apps/birdseye/internal/diff [build failed]
FAIL


Good, compile errors as expected. Now **Step 3: Implement types.go** and **Step 4: Implement textual.go**, but first I need to add `gotextdiff` to go.mod.


Write

// Package diff produces FileDiff entries and optional SemanticChange
// entries from a pair of extracted Modules. All functions are pure: no
// I/O, no globals.
package diff

// FileStatus describes what happened to a file between the two versions.
type FileStatus int

const (
	StatusModified FileStatus = iota
	StatusAdded
	StatusRemoved
)

func (s FileStatus) String() string {
	switch s {
	case StatusAdded:
		return "added"
	case StatusRemoved:
		return "removed"
	default:
		return "modified"
	}
}

// FileDiff is a per-file diff result. Unified holds the rendered diff
// hunks; Adds/Dels are the count of added/removed lines for summary
// rendering.
type FileDiff struct {
	Path    string
	Status  FileStatus
	Unified string
	Adds    int
	Dels    int
}

// SemanticChange is a single HCL-aware change. Kind is one of "resource",
// "variable", "output", "provider", "locals", "data", or "module".
type SemanticChange struct {
	Kind   string
	Name   string
	Status FileStatus
	Detail string
}

// Result is what cmd/diff renders. Semantic is nil unless --semantic was
// requested.
type Result struct {
	Files    []FileDiff
	Semantic []SemanticChange
}


File created successfully at: /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye/internal/diff/types.go (file state is current in your context — no need to Read it back)


Now add `gotextdiff` to go.mod before writing textual.go:


Bash

cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye && GOFLAGS="" GONOSUMCHECK="*" GONOSUMDB="*" GOFLAGS="" go get github.com/hexops/gotextdiff@latest 2>&1

Add gotextdiff dependency


go: added github.com/hexops/gotextdiff v1.0.3


`gotextdiff` was added as indirect. Now write textual.go and then run `go mod tidy` to make it direct:


Write

package diff

import (
	"sort"
	"strings"

	"github.com/hexops/gotextdiff"
	"github.com/hexops/gotextdiff/myers"
	"github.com/hexops/gotextdiff/span"

	"github.com/immuta/operations/apps/birdseye/internal/archive"
)

// Textual returns a FileDiff per file that differs between a and b,
// sorted by path. Files identical in both archives are not included.
func Textual(a, b archive.Module) []FileDiff {
	all := make(map[string]struct{})
	for k := range a.Files {
		all[k] = struct{}{}
	}
	for k := range b.Files {
		all[k] = struct{}{}
	}
	paths := make([]string, 0, len(all))
	for k := range all {
		paths = append(paths, k)
	}
	sort.Strings(paths)

	out := make([]FileDiff, 0, len(paths))
	for _, p := range paths {
		ab, aok := a.Files[p]
		bb, bok := b.Files[p]
		switch {
		case aok && bok:
			if string(ab) == string(bb) {
				continue
			}
			out = append(out, modifiedDiff(p, string(ab), string(bb)))
		case !aok && bok:
			out = append(out, addedDiff(p, string(bb)))
		case aok && !bok:
			out = append(out, removedDiff(p, string(ab)))
		}
	}
	return out
}

func modifiedDiff(path, oldText, newText string) FileDiff {
	edits := myers.ComputeEdits(span.URIFromPath(path), oldText, newText)
	unified := gotextdiff.ToUnified(path, path, oldText, edits)
	u := unified.String()
	adds, dels := countLines(u)
	return FileDiff{Path: path, Status: StatusModified, Unified: u, Adds: adds, Dels: dels}
}

func addedDiff(path, newText string) FileDiff {
	return FileDiff{
		Path:    path,
		Status:  StatusAdded,
		Unified: "+++ " + path + "\n" + prefixLines(newText, "+"),
		Adds:    countNonEmptyLines(newText),
	}
}

func removedDiff(path, oldText string) FileDiff {
	return FileDiff{
		Path:    path,
		Status:  StatusRemoved,
		Unified: "--- " + path + "\n" + prefixLines(oldText, "-"),
		Dels:    countNonEmptyLines(oldText),
	}
}

func prefixLines(s, prefix string) string {
	if s == "" {
		return ""
	}
	lines := strings.Split(strings.TrimRight(s, "\n"), "\n")
	var sb strings.Builder
	sb.Grow(len(s) + len(lines)*len(prefix))
	for _, l := range lines {
		sb.WriteString(prefix)
		sb.WriteString(l)
		sb.WriteByte('\n')
	}
	return sb.String()
}

func countNonEmptyLines(s string) int {
	if s == "" {
		return 0
	}
	n := strings.Count(s, "\n")
	if !strings.HasSuffix(s, "\n") {
		n++
	}
	return n
}

func countLines(unified string) (adds, dels int) {
	for _, line := range strings.Split(unified, "\n") {
		if len(line) == 0 {
			continue
		}
		switch line[0] {
		case '+':
			if !strings.HasPrefix(line, "+++") {
				adds++
			}
		case '-':
			if !strings.HasPrefix(line, "---") {
				dels++
			}
		}
	}
	return adds, dels
}


File created successfully at: /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye/internal/diff/textual.go (file state is current in your context — no need to Read it back)


Bash

cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye && go mod tidy 2>&1

Run go mod tidy to promote gotextdiff to direct dependency


Verify gotextdiff is now a direct dependency


github.com/hexops/gotextdiff v1.0.3


Good, it's now a direct dependency (no `// indirect` comment). **Step 5: Run tests, expect PASS**


cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye && go test -race ./internal/diff/... -v 2>&1

Run all diff tests with race detector verbose


Error: Exit code 1
# github.com/immuta/operations/apps/birdseye/internal/diff [github.com/immuta/operations/apps/birdseye/internal/diff.test]
internal/diff/textual.go:52:15: unified.String undefined (type gotextdiff.Unified has no field or method String)
FAIL	github.com/immuta/operations/apps/birdseye/internal/diff [build failed]
FAIL


Need to check the actual gotextdiff API:


find /Users/steve.briggs/go /root/go $(go env GOPATH) -path "*/hexops/gotextdiff/*.go" 2>/dev/null | head -20

Find gotextdiff source files


cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye && go env GOMODCACHE 2>&1

Find Go module cache path


find /Users/steve.briggs/go/pkg/mod/github.com/hexops -name "*.go" 2>/dev/null | head -20

Find gotextdiff source in module cache


1	// Copyright 2019 The Go Authors. All rights reserved.
2	// Use of this source code is governed by a BSD-style
3	// license that can be found in the LICENSE file.
4
5	package gotextdiff
6
7	import (
8		"fmt"
9		"strings"
10	)
11
12	// Unified represents a set of edits as a unified diff.
13	type Unified struct {
14		// From is the name of the original file.
15		From string
16		// To is the name of the modified file.
17		To string
18		// Hunks is the set of edit hunks needed to transform the file content.
19		Hunks []*Hunk
20	}
21
22	// Hunk represents a contiguous set of line edits to apply.
23	type Hunk struct {
24		// The line in the original source where the hunk starts.
25		FromLine int
26		// The line in the original source where the hunk finishes.
27		ToLine int
28		// The set of line based edits to apply.
29		Lines []Line
30	}
31
32	// Line represents a single line operation to apply as part of a Hunk.
33	type Line struct {
34		// Kind is the type of line this represents, deletion, insertion or copy.
35		Kind OpKind
36		// Content is the content of this line.
37		// For deletion it is the line being removed, for all others it is the line
38		// to put in the output.
39		Content string
40	}
41
42	// OpKind is used to denote the type of operation a line represents.
43	type OpKind int
44
45	const (
46		// Delete is the operation kind for a line that is present in the input
47		// but not in the output.
48		Delete OpKind = iota
49		// Insert is the operation kind for a line that is new in the output.
50		Insert
51		// Equal is the operation kind for a line that is the same in the input and
52		// output, often used to provide context around edited lines.
53		Equal
54	)
55
56	// String returns a human readable representation of an OpKind. It is not
57	// intended for machine processing.
58	func (k OpKind) String() string {
59		switch k {
60		case Delete:
61			return "delete"
62		case Insert:
63			return "insert"
64		case Equal:
65			return "equal"
66		default:
67			panic("unknown operation kind")
68		}
69	}
70
71	const (
72		edge = 3
73		gap  = edge * 2
74	)
75
76	// ToUnified takes a file contents and a sequence of edits, and calculates
77	// a unified diff that represents those edits.
78	func ToUnified(from, to string, content string, edits []TextEdit) Unified {
79		u := Unified{
80			From: from,
81			To:   to,
82		}
83		if len(edits) == 0 {
84			return u
85		}
86		c, edits, partial := prepareEdits(content, edits)
87		if partial {
88			edits = lineEdits(content, c, edits)
89		}
90		lines := splitLines(content)
91		var h *Hunk
92		last := 0
93		toLine := 0
94		for _, edit := range edits {
95			start := edit.Span.Start().Line() - 1
96			end := edit.Span.End().Line() - 1
97			switch {
98			case h != nil && start == last:
99				//direct extension
100			case h != nil && start <= last+gap:
101				//within range of previous lines, add the joiners
102				addEqualLines(h, lines, last, start)
103			default:
104				//need to start a new hunk
105				if h != nil {
106					// add the edge to the previous hunk
107					addEqualLines(h, lines, last, last+edge)
108					u.Hunks = append(u.Hunks, h)
109				}
110				toLine += start - last
111				h = &Hunk{
112					FromLine: start + 1,
113					ToLine:   toLine + 1,
114				}
115				// add the edge to the new hunk
116				delta := addEqualLines(h, lines, start-edge, start)
117				h.FromLine -= delta
118				h.ToLine -= delta
119			}
120			last = start
121			for i := start; i < end; i++ {
122				h.Lines = append(h.Lines, Line{Kind: Delete, Content: lines[i]})
123				last++
124			}
125			if edit.NewText != "" {
126				for _, line := range splitLines(edit.NewText) {
127					h.Lines = append(h.Lines, Line{Kind: Insert, Content: line})
128					toLine++
129				}
130			}
131		}
132		if h != nil {
133			// add the edge to the final hunk
134			addEqualLines(h, lines, last, last+edge)
135			u.Hunks = append(u.Hunks, h)
136		}
137		return u
138	}
139
140	func splitLines(text string) []string {
141		lines := strings.SplitAfter(text, "\n")
142		if lines[len(lines)-1] == "" {
143			lines = lines[:len(lines)-1]
144		}
145		return lines
146	}
147
148	func addEqualLines(h *Hunk, lines []string, start, end int) int {
149		delta := 0
150		for i := start; i < end; i++ {
151			if i < 0 {
152				continue
153			}
154			if i >= len(lines) {
155				return delta
156			}
157			h.Lines = append(h.Lines, Line{Kind: Equal, Content: lines[i]})
158			delta++
159		}
160		return delta
161	}
162
163	// Format converts a unified diff to the standard textual form for that diff.
164	// The output of this function can be passed to tools like patch.
165	func (u Unified) Format(f fmt.State, r rune) {
166		if len(u.Hunks) == 0 {
167			return
168		}
169		fmt.Fprintf(f, "--- %s\n", u.From)
170		fmt.Fprintf(f, "+++ %s\n", u.To)
171		for _, hunk := range u.Hunks {
172			fromCount, toCount := 0, 0
173			for _, l := range hunk.Lines {
174				switch l.Kind {
175				case Delete:
176					fromCount++
177				case Insert:
178					toCount++
179				default:
180					fromCount++
181					toCount++
182				}
183			}
184			fmt.Fprint(f, "@@")
185			if fromCount > 1 {
186				fmt.Fprintf(f, " -%d,%d", hunk.FromLine, fromCount)
187			} else {
188				fmt.Fprintf(f, " -%d", hunk.FromLine)
189			}
190			if toCount > 1 {
191				fmt.Fprintf(f, " +%d,%d", hunk.ToLine, toCount)
192			} else {
193				fmt.Fprintf(f, " +%d", hunk.ToLine)
194			}
195			fmt.Fprint(f, " @@\n")
196			for _, l := range hunk.Lines {
197				switch l.Kind {
198				case Delete:
199					fmt.Fprintf(f, "-%s", l.Content)
200				case Insert:
201					fmt.Fprintf(f, "+%s", l.Content)
202				default:
203					fmt.Fprintf(f, " %s", l.Content)
204				}
205				if !strings.HasSuffix(l.Content, "\n") {
206					fmt.Fprintf(f, "\n\\ No newline at end of file\n")
207				}
208			}
209		}
210	}
211


The `Unified` type implements `fmt.Formatter` (via `Format(f fmt.State, r rune)`), not `Stringer`. I need to use `fmt.Sprintf("%v", unified)` instead of `unified.String()`.


Edit

import (
	"sort"
	"strings"

	"github.com/hexops/gotextdiff"
	"github.com/hexops/gotextdiff/myers"
	"github.com/hexops/gotextdiff/span"

	"github.com/immuta/operations/apps/birdseye/internal/archive"
)

import (
	"fmt"
	"sort"
	"strings"

	"github.com/hexops/gotextdiff"
	"github.com/hexops/gotextdiff/myers"
	"github.com/hexops/gotextdiff/span"

	"github.com/immuta/operations/apps/birdseye/internal/archive"
)


<tool_use_error>File does not exist. Note: your current working directory is /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye.</tool_use_error>

Error: File does not exist. Note: your current working directory is /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye.


import (
	"sort"
	"strings"

	"github.com/hexops/gotextdiff"
	"github.com/hexops/gotextdiff/myers"
	"github.com/hexops/gotextdiff/span"

	"github.com/immuta/operations/apps/birdseye/internal/archive"
)

import (
	"fmt"
	"sort"
	"strings"

	"github.com/hexops/gotextdiff"
	"github.com/hexops/gotextdiff/myers"
	"github.com/hexops/gotextdiff/span"

	"github.com/immuta/operations/apps/birdseye/internal/archive"
)


The file /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye/internal/diff/textual.go has been updated successfully. (file state is current in your context — no need to Read it back)


	edits := myers.ComputeEdits(span.URIFromPath(path), oldText, newText)
	unified := gotextdiff.ToUnified(path, path, oldText, edits)
	u := unified.String()
	adds, dels := countLines(u)

	edits := myers.ComputeEdits(span.URIFromPath(path), oldText, newText)
	unified := gotextdiff.ToUnified(path, path, oldText, edits)
	u := fmt.Sprintf("%v", unified)
	adds, dels := countLines(u)


Bash

cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye && go test -race ./internal/diff/... -v 2>&1

Run all diff tests with race detector verbose


=== RUN   TestTextualAdded
--- PASS: TestTextualAdded (0.00s)
=== RUN   TestTextualRemoved
--- PASS: TestTextualRemoved (0.00s)
=== RUN   TestTextualModified
--- PASS: TestTextualModified (0.00s)
=== RUN   TestTextualIdenticalReturnsEmpty
--- PASS: TestTextualIdenticalReturnsEmpty (0.00s)
=== RUN   TestTextualSortedByPath
--- PASS: TestTextualSortedByPath (0.00s)
PASS
ok  	github.com/immuta/operations/apps/birdseye/internal/diff	1.679s


All 5 tests pass. Now run `go vet`:


cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye && go vet ./... 2>&1

Run go vet on all packages


(Bash completed with no output)


Clean. Now **Step 6: Commit**


git diff apps/birdseye/go.mod apps/birdseye/go.sum 2>&1 | head -30

Check if go.mod and go.sum were modified


diff --git a/apps/birdseye/go.mod b/apps/birdseye/go.mod
index fdd5177..93c54a8 100644
--- a/apps/birdseye/go.mod
+++ b/apps/birdseye/go.mod
@@ -3,6 +3,7 @@ module github.com/immuta/operations/apps/birdseye
 go 1.25.5

 require (
+	github.com/hexops/gotextdiff v1.0.3
 	github.com/immuta/operations/services/crow v0.0.0-20260512210412-d8eb33139c1a
 	github.com/spf13/cobra v1.9.1
 	golang.org/x/sync v0.20.0
diff --git a/apps/birdseye/go.sum b/apps/birdseye/go.sum
index 7f4069b..f201662 100644
--- a/apps/birdseye/go.sum
+++ b/apps/birdseye/go.sum
@@ -1,4 +1,6 @@
 github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
+github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
+github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
 github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=


Both go.mod and go.sum were modified. Include them in the commit:


git add apps/birdseye/internal/diff/ apps/birdseye/go.mod apps/birdseye/go.sum && git commit -m "$(cat <<'EOF'
feat(birdseye): textual unified diff between extracted modules

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

Commit the diff package implementation


[worktree-birdseye 16193cc] feat(birdseye): textual unified diff between extracted modules
 5 files changed, 259 insertions(+)
 create mode 100644 apps/birdseye/internal/diff/textual.go
 create mode 100644 apps/birdseye/internal/diff/textual_test.go
 create mode 100644 apps/birdseye/internal/diff/types.go


**Status:** DONE

**Files created:**
- `/Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye/internal/diff/types.go`
- `/Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye/internal/diff/textual.go`
- `/Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye/internal/diff/textual_test.go`

**Test count + pass status:** 5/5 PASS with `-race`

**go.mod/go.sum changes:** `github.com/hexops/gotextdiff v1.0.3` added as a direct dependency (was not in go.mod prior to this task). go.sum updated accordingly.

**Commit SHA:** `16193cc`

**One deviation from the plan:** The plan used `unified.String()` but `gotextdiff.Unified` implements `fmt.Formatter` (not `fmt.Stringer`). Used `fmt.Sprintf("%v", unified)` instead — same output, all tests pass.