From 19c6f17eb27c9df47d267286c6bbb7d4fa317a2a Mon Sep 17 00:00:00 2001 From: Simon Franken Date: Wed, 21 Oct 2026 15:55:46 +0200 Subject: [PATCH] Preview uncommitted changes in the detail pane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For a dirty submodule, list the changed files so the user can remember what they were doing there: Changes 5 files · 2M 1A 1D 1R · +37 −12 ────────────────────────────────── M render.go src/render/ +20 −4 3d ago ? scratch.txt 5m ago - Status.Changes: files changed against HEAD (staged and unstaged combined) with kind, line counts and modification time; sorted by kind, then by size; capped at 200 with totals over all files. - Git provider: parse `status --porcelain=v2 -z` and one `diff HEAD --numstat -z -M`, only for dirty submodules. - Detail pane: flat list with a colored status letter per file, like IDE source-control views; counts and age in compact columns; up to 10 files, every kind keeps a line, the rest is summarized as "n more". - Fixture gains libs/work with every kind of change; mock data extended. - Docs: F24, change preview in the UX guide, D17. Co-Authored-By: Claude Opus 5.5 --- docs/architecture.md | 6 +- docs/decisions.md | 18 ++ docs/requirements.md | 7 + docs/state-model.md | 28 ++- docs/ux-guide.md | 54 +++++- internal/submodule/git/parse.go | 110 ++++++++++- internal/submodule/git/parse_test.go | 71 ++++++- internal/submodule/git/provider.go | 49 ++++- internal/submodule/git/provider_test.go | 91 ++++++++- internal/submodule/mock/mock.go | 39 ++++ internal/submodule/model.go | 70 ++++++- internal/tui/detail/changes.go | 234 ++++++++++++++++++++++++ internal/tui/detail/changes_test.go | 65 +++++++ internal/tui/detail/detail.go | 4 + internal/tui/theme/issues.go | 36 ++++ internal/tui/theme/theme.go | 8 +- scripts/make-fixture.sh | 24 ++- 17 files changed, 882 insertions(+), 32 deletions(-) create mode 100644 internal/tui/detail/changes.go create mode 100644 internal/tui/detail/changes_test.go diff --git a/docs/architecture.md b/docs/architecture.md index 850015b..6a88334 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -15,16 +15,16 @@ internal/submodule/ domain model; NO UI imports provider.go Provider interface internal/submodule/git/ read-only provider using the git CLI exec.go git runner (environment, errors) - parse.go pure parsers for git output + parse.go pure parsers for git output (status, numstat, …) provider.go Tree, Status, pin → ref resolution internal/submodule/mock/ fixture provider with artificial latency internal/tui/ Bubble Tea application app.go root model: layout, message routing, loading keys.go key bindings (also feed the help view) msgs.go treeLoadedMsg, statusLoadedMsg - theme/ colors, glyphs, issue words, level styles + theme/ colors, glyphs, issue words, change letters, level styles tree/ tree component: rows, navigation, mouse, view - detail/ detail pane + detail/ detail pane; changes.go: change preview header/ header line footer/ help and flash messages ``` diff --git a/docs/decisions.md b/docs/decisions.md index d6f9b5e..ce9746e 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -128,3 +128,21 @@ processes. **Decision.** The git provider limits concurrent `Status` calls to `runtime.NumCPU()` with a semaphore that respects `ctx`. **Consequences.** The UI stays unchanged; rows fill in steadily. + +## D17 — Change preview: flat letter list against HEAD + +**Context.** `changed` doesn't tell users what they changed or why; they +want to remember what they were doing in a submodule. +**Decision.** `Status.Changes` lists the changed files against HEAD (staged +and unstaged combined) with line counts. The detail pane shows them as a +flat list with a colored status letter in front, like IDE source-control +views, instead of group headers. Sorted by kind, then by size; the 10-line +budget keeps one line for every kind present and summarizes the rest. +Placed directly below the issue lines, because the pane is cut by height. +One summary line for all files, then strictly one line per file with its +own age: a second summary line ("+1 · edited 19m ago") read as if it +belonged to the first file. +Dirt inside nested submodules is ignored (D7); unstaged moves show as +removed + untracked. +**Consequences.** One extra `git diff` per dirty submodule, none for clean +ones. Not included: staged/unstaged markers, diff bodies, stash count. diff --git a/docs/requirements.md b/docs/requirements.md index 368ee6f..d0b0d0f 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -61,6 +61,13 @@ commit hashes. It doesn't answer the questions users actually have: - **F22** Lists the commits between pin and tip (SHA, subject, author, age) with a title stating ahead/behind/diverged. - **F23** Shows child count, issues below, and the remote URL. +- **F24** For a submodule with uncommitted changes, previews the changed + files so the user can remember what they were doing: a flat list with a + colored letter per file (`M` modified, `A` added, `D` removed, `R` + renamed, `?` untracked, `!` conflict), line counts (`+n −m`) per file and + in total, and how long ago each file was edited. Only a handful of files are + shown; the rest is summarized as "n more" with their kinds. Staged and + unstaged changes are combined. ### Header and footer diff --git a/docs/state-model.md b/docs/state-model.md index d260c07..c72c288 100644 --- a/docs/state-model.md +++ b/docs/state-model.md @@ -32,10 +32,29 @@ in several at once. | `Pin` | `Ref`, `Source`, `Ahead`, `Behind`, `Pushed` | | `Pinned`, `CheckedOut`, `Tip` | Commits (SHA, subject, author, time) | | `Between` | Commits that make up the pin ↔ tip difference | +| `Changes` | Changed files of the worktree (nil if clean), see below | `Status == nil` means "still loading". `Submodule.Err` is set if loading failed. +## Changes + +`Status.Changes` describes *what* is behind `Worktree`: one `FileChange` per +path with a `ChangeKind` (conflicted, modified, added, removed, renamed, +untracked), added/removed line counts, and flags for binary files and moved +nested submodule pointers. It compares the worktree **with HEAD**, so staged +and unstaged changes are combined (a file added to the index and edited +afterwards is one added file). + +- `Files` is sorted by kind (that order), then by changed lines (largest + first), then by path, and capped at `MaxChangeFiles` (200). `Total`, + `Added` and `Removed` cover all files. +- `FileChange.Modified` is the file's modification time (zero for removed + files). +- `Worktree` is derived from the same list: conflict, else any tracked + change → modified, else untracked. +- It is a preview, not an issue: it adds no issue kinds and changes no levels. + ## Issues: summarizing the dimensions The UI never shows raw dimensions in the tree. `Status.Issues()` summarizes @@ -107,7 +126,9 @@ are local; `` is the index gitlink. | `Pinned`, `PinStaged` | parent: `ls-files -s -- ` vs `ls-tree HEAD -- ` | | `Presence` | directory missing → `DirMissing`; no `.git` in it → `NotInitialized` | | `Checkout` | `rev-parse HEAD`, `symbolic-ref -q HEAD` (no branch → detached) | -| `Worktree` | `status --porcelain=v2 --ignore-submodules=dirty` | +| `Worktree`, `Changes` kinds | `status --porcelain=v2 -z --untracked-files=normal --ignore-submodules=dirty` | +| `Changes` line counts | `diff HEAD --numstat -z -M --ignore-submodules=dirty`, only if a tracked file changed; failure leaves the counts at 0 | +| `FileChange.Modified` | `stat` of each changed file that still exists | | `Pushed` | `for-each-ref --contains refs/remotes` is not empty | | `Pin.Ref` | rules above; the remote is `origin`, else the first remote | | `Ahead`, `Behind` | `rev-list --left-right --count ...` | @@ -123,6 +144,11 @@ Details: - Rule 6 shows the short SHA; there is no tip and no distance. - `--ignore-submodules=dirty`: changes inside a nested submodule do not make its parent `changed` (D7), but a moved nested pin does. +- Renames are detected only for staged moves (`git mv`); an unstaged move + is a removed file plus an untracked file. Untracked directories are one + entry (`dir/`) without line counts. +- `--untracked-files=normal` overrides `status.showUntrackedFiles`, so the + result doesn't depend on the user's config. - A missing submodule only gets a `Pinned` SHA; nested submodules of an uninitialized submodule are unknown and not shown. - If the pinned commit doesn't exist in the submodule (not fetched), loading diff --git a/docs/ux-guide.md b/docs/ux-guide.md index fd9dab3..1c98197 100644 --- a/docs/ux-guide.md +++ b/docs/ux-guide.md @@ -61,12 +61,58 @@ indent ▸ name [n] status ref delta 2. One line per issue: word (colored, 10 columns) + explanation; `in sync nothing to do` if there are none; `staged` and `detached HEAD on pin` as information -3. `Ref` with distance and `via ` -4. `Pinned`, `Checkout`, `Tip`: SHA + subject; the checkout SHA is yellow if it +3. `Changes` (only with uncommitted changes), see + [Change preview](#change-preview) +4. `Ref` with distance and `via ` +5. `Pinned`, `Checkout`, `Tip`: SHA + subject; the checkout SHA is yellow if it differs from the pin -5. "Pin is n commits behind/ahead of …" / "Pin and … have diverged": commit list -6. `Children` (count and issues below), `URL` +6. "Pin is n commits behind/ahead of …" / "Pin and … have diverged": commit list +7. `Children` (count and issues below), `URL` +#### Change preview + +``` +Changes 5 files · 2M 1A 1D 1R · +37 −12 + ────────────────────────────────────── + M render.go src/render/ +20 −4 3d ago + M README.md +3 −1 20m ago + A notes.md docs/ +14 2h ago + D old.go src/ −8 + R new.go ← old.go 1d ago + ? scratch.txt 5m ago + … 3 more (2M 1?) +``` + +- A flat list, no group headers: IDEs (VS Code, JetBrains) do the same, and + headers would use up the few lines the preview has. The letter is the + meaning, the color only supports it (N4). +- Letters and colors: `!` red bold, `M` yellow, `A` green, `D` red, `R` + cyan, `?` muted. Line counts: green `+n`, red `−n`; `bin` for binary + files, `submodule` for a moved nested pin, nothing for untracked files. +- Order: kind (`!`, `M`, `A`, `D`, `R`, `?`), then changed lines, largest + first, because the biggest edits are the ones people remember. +- Budget: 10 files. Every kind that occurs gets at least one line, the + rest is filled in sort order. `… n more ()` summarizes the hidden + files. With 11 files all are shown: "1 more" would take the same line as + the file itself. +- The `Changes` line summarizes all files: count, files per kind, line + totals. Everything below it describes one file per line, so no line can + be mistaken for a file's detail. In a narrow pane the line totals are + dropped rather than cut. +- A muted rule (`─`, `-` in ASCII mode) separates the summary from the + files. It starts in the value column and is as wide as the summary or + the file table, whichever is wider. +- The age (muted) is the file's modification time, so the most recently + touched files stand out. Removed files have no age. +- The list starts in the value column (under `5 files`), like every other + value in the pane. +- File name bold, directory muted. Counts form a right-aligned column + directly after the widest path (max 48 columns), not at the pane's edge, + so they stay next to their file in wide panes. If space is short the age + column goes first, then the directory is cut from the left + (`…ernal/client/`), the name last. For + renames the muted part is `← old/path`. +- Untracked directories are shown as one entry (`tmp/`). ### Header `path branch ↑↓ · n submodules · … · n pin staged · n loading · fetched ` diff --git a/internal/submodule/git/parse.go b/internal/submodule/git/parse.go index 96b2418..a078ee0 100644 --- a/internal/submodule/git/parse.go +++ b/internal/submodule/git/parse.go @@ -56,23 +56,119 @@ func parseGitmodules(out string) []gitmodule { return mods } -// parseWorktree summarizes `git status --porcelain=v2` output. A conflict -// outranks modifications, which outrank untracked files. -func parseWorktree(out string) submodule.Worktree { +// entry is one changed path of `git status --porcelain=v2 -z`. +type entry struct { + path, orig string + kind submodule.ChangeKind + tracked bool // false for untracked files + submodule bool // a nested submodule pointer changed +} + +// parseStatus parses `git status --porcelain=v2 -z`. Entries that don't +// differ from HEAD (e.g. added to the index, then deleted again) are +// dropped. +func parseStatus(out string) []entry { + var es []entry + recs := strings.Split(out, "\x00") + for i := 0; i < len(recs); i++ { + rec := recs[i] + if len(rec) < 3 { + continue + } + switch rec[0] { + case '?': + es = append(es, entry{path: rec[2:], kind: submodule.ChangeUntracked}) + case 'u': // u XY sub m1 m2 m3 mW h1 h2 h3 path + if f := strings.SplitN(rec, " ", 11); len(f) == 11 { + es = append(es, entry{path: f[10], kind: submodule.ChangeConflicted, tracked: true}) + } + case '1': // 1 XY sub mH mI mW hH hI path + if f := strings.SplitN(rec, " ", 9); len(f) == 9 { + if e, ok := trackedEntry(f[1], f[2], f[8], ""); ok { + es = append(es, e) + } + } + case '2': // 2 XY sub mH mI mW hH hI Xscore path NUL origPath + if f := strings.SplitN(rec, " ", 10); len(f) == 10 && i+1 < len(recs) { + i++ + if e, ok := trackedEntry(f[1], f[2], f[9], recs[i]); ok { + es = append(es, e) + } + } + } + } + return es +} + +func trackedEntry(xy, sub, path, orig string) (entry, bool) { + if len(xy) != 2 { + return entry{}, false + } + x, y := xy[0], xy[1] + e := entry{path: path, orig: orig, tracked: true, submodule: strings.HasPrefix(sub, "S")} + switch { + case x == 'R' || y == 'R': + e.kind = submodule.ChangeRenamed + case x == 'A' && y == 'D': + return entry{}, false + case x == 'A' || y == 'A' || x == 'C' || y == 'C': + e.kind = submodule.ChangeAdded + case x == 'D' || y == 'D': + e.kind = submodule.ChangeRemoved + default: + e.kind = submodule.ChangeModified + } + return e, true +} + +// worktreeOf summarizes entries: a conflict outranks tracked changes, which +// outrank untracked files. +func worktreeOf(es []entry) submodule.Worktree { w := submodule.WorktreeClean - for _, line := range strings.Split(out, "\n") { + for _, e := range es { switch { - case strings.HasPrefix(line, "u "): + case e.kind == submodule.ChangeConflicted: return submodule.WorktreeConflict - case strings.HasPrefix(line, "1 "), strings.HasPrefix(line, "2 "): + case e.tracked: w = submodule.WorktreeModified - case strings.HasPrefix(line, "? ") && w == submodule.WorktreeClean: + case w == submodule.WorktreeClean: w = submodule.WorktreeUntracked } } return w } +// numstat holds the line counts of one file. +type numstat struct { + added, removed int + binary bool +} + +// parseNumstat parses `git diff --numstat -z -M`, keyed by the (new) path. +func parseNumstat(out string) map[string]numstat { + m := map[string]numstat{} + recs := strings.Split(out, "\x00") + for i := 0; i < len(recs); i++ { + f := strings.SplitN(recs[i], "\t", 3) + if len(f) != 3 { + continue + } + path := f[2] + if path == "" { // rename: the next two records are old and new path + if i+2 >= len(recs) { + break + } + path = recs[i+2] + i += 2 + } + n := numstat{binary: f[0] == "-"} + n.added, _ = strconv.Atoi(f[0]) + n.removed, _ = strconv.Atoi(f[1]) + m[path] = n + } + return m +} + // parseLeftRight parses `git rev-list --left-right --count a...b`. func parseLeftRight(out string) (left, right int, err error) { f := strings.Fields(out) diff --git a/internal/submodule/git/parse_test.go b/internal/submodule/git/parse_test.go index a3c5a45..9fce762 100644 --- a/internal/submodule/git/parse_test.go +++ b/internal/submodule/git/parse_test.go @@ -2,6 +2,7 @@ package git import ( "slices" + "strings" "testing" "submarine/internal/submodule" @@ -26,20 +27,72 @@ func TestParseGitmodules(t *testing.T) { } } -func TestParseWorktree(t *testing.T) { +func z(recs ...string) string { return strings.Join(recs, "\x00") + "\x00" } + +func TestParseStatus(t *testing.T) { + out := z( + "1 .M N... 100644 100644 100644 a b src/my file.go", + "1 A. N... 000000 100644 100644 a b new.go", + "1 AD N... 000000 100644 000000 a b gone.go", + "1 D. N... 100644 000000 000000 a b old.go", + "1 .M S.M. 160000 160000 160000 a b libs/nested", + "2 R. N... 100644 100644 100644 a b R100 dir/ü new.go", "dir/old.go", + "u UU N... 100644 100644 100644 100644 a b c conflict.go", + "? scratch dir/", + "! ignored.o", + ) + want := []entry{ + {path: "src/my file.go", kind: submodule.ChangeModified, tracked: true}, + {path: "new.go", kind: submodule.ChangeAdded, tracked: true}, + {path: "old.go", kind: submodule.ChangeRemoved, tracked: true}, + {path: "libs/nested", kind: submodule.ChangeModified, tracked: true, submodule: true}, + {path: "dir/ü new.go", orig: "dir/old.go", kind: submodule.ChangeRenamed, tracked: true}, + {path: "conflict.go", kind: submodule.ChangeConflicted, tracked: true}, + {path: "scratch dir/", kind: submodule.ChangeUntracked}, + } + if got := parseStatus(out); !slices.Equal(got, want) { + t.Errorf("parseStatus() =\n%+v\nwant\n%+v", got, want) + } + if got := parseStatus(""); got != nil { + t.Errorf("parseStatus(\"\") = %+v, want nil", got) + } +} + +func TestWorktreeOf(t *testing.T) { + untracked := entry{kind: submodule.ChangeUntracked} + modified := entry{kind: submodule.ChangeModified, tracked: true} + conflict := entry{kind: submodule.ChangeConflicted, tracked: true} tests := []struct { - out string + es []entry want submodule.Worktree }{ - {"", submodule.WorktreeClean}, - {"? new.txt", submodule.WorktreeUntracked}, - {"? new.txt\n1 .M N... 100644 100644 100644 a b README", submodule.WorktreeModified}, - {"2 R. N... 100644 100644 100644 a b R100 new\told", submodule.WorktreeModified}, - {"1 .M N... 100644 100644 100644 a b README\nu UU N... 1 2 3 4 a b c README", submodule.WorktreeConflict}, + {nil, submodule.WorktreeClean}, + {[]entry{untracked}, submodule.WorktreeUntracked}, + {[]entry{untracked, modified}, submodule.WorktreeModified}, + {[]entry{modified, untracked, conflict}, submodule.WorktreeConflict}, } for _, tt := range tests { - if got := parseWorktree(tt.out); got != tt.want { - t.Errorf("parseWorktree(%q) = %v, want %v", tt.out, got, tt.want) + if got := worktreeOf(tt.es); got != tt.want { + t.Errorf("worktreeOf(%+v) = %v, want %v", tt.es, got, tt.want) + } + } +} + +func TestParseNumstat(t *testing.T) { + out := z("3\t1\tREADME.md", "-\t-\timg/logo.png", "0\t0\t", "old name.go", "new name.go", "5\t0\tsrc/a b.go") + want := map[string]numstat{ + "README.md": {added: 3, removed: 1}, + "img/logo.png": {binary: true}, + "new name.go": {}, + "src/a b.go": {added: 5}, + } + got := parseNumstat(out) + if len(got) != len(want) { + t.Fatalf("parseNumstat() = %+v, want %+v", got, want) + } + for k, w := range want { + if got[k] != w { + t.Errorf("parseNumstat()[%q] = %+v, want %+v", k, got[k], w) } } } diff --git a/internal/submodule/git/provider.go b/internal/submodule/git/provider.go index ce73455..e487973 100644 --- a/internal/submodule/git/provider.go +++ b/internal/submodule/git/provider.go @@ -11,6 +11,7 @@ import ( "path" "path/filepath" "runtime" + "slices" "strings" "sync" @@ -182,11 +183,15 @@ func status(ctx context.Context, in info) (*submodule.Status, error) { case !onBranch: st.Checkout = submodule.CheckoutDetached } - out, err = run(ctx, in.dir, "status", "--porcelain=v2", "--ignore-submodules=dirty") + out, err = run(ctx, in.dir, "status", "--porcelain=v2", "-z", "--untracked-files=normal", "--ignore-submodules=dirty") if err != nil { return nil, err } - st.Worktree = parseWorktree(out) + entries := parseStatus(out) + st.Worktree = worktreeOf(entries) + if st.Changes, err = changes(ctx, in.dir, entries); err != nil { + return nil, err + } // the pin must exist locally to say anything about it if _, ok, err := try(ctx, in.dir, "cat-file", "-e", pin+"^{commit}"); err != nil { @@ -341,6 +346,46 @@ func remoteName(ctx context.Context, dir string) (string, error) { return "", nil } +// changes describes the changed files of the worktree; nil if there are +// none. Line counts come from one `git diff HEAD`; if that fails (e.g. no +// commit yet) they stay zero. +func changes(ctx context.Context, dir string, entries []entry) (*submodule.Changes, error) { + if len(entries) == 0 { + return nil, nil + } + var counts map[string]numstat + if slices.ContainsFunc(entries, func(e entry) bool { return e.tracked && !e.submodule }) { + out, ok, err := try(ctx, dir, "diff", "HEAD", "--numstat", "-z", "-M", "--no-ext-diff", "--ignore-submodules=dirty") + if err != nil { + return nil, err + } + if ok { + counts = parseNumstat(out) + } + } + + c := &submodule.Changes{Total: len(entries)} + for _, e := range entries { + f := submodule.FileChange{Path: e.path, OrigPath: e.orig, Kind: e.kind, Submodule: e.submodule} + if n, ok := counts[e.path]; ok && !e.submodule { + f.Added, f.Removed, f.Binary = n.added, n.removed, n.binary + c.Added += n.added + c.Removed += n.removed + } + if e.kind != submodule.ChangeRemoved { + if fi, err := os.Stat(filepath.Join(dir, filepath.FromSlash(e.path))); err == nil { + f.Modified = fi.ModTime() + } + } + c.Files = append(c.Files, f) + } + c.Sort() + if len(c.Files) > submodule.MaxChangeFiles { + c.Files = c.Files[:submodule.MaxChangeFiles] + } + return c, nil +} + func commit(ctx context.Context, dir, rev string) (*submodule.Commit, error) { out, err := run(ctx, dir, "log", "-1", commitFormat, rev, "--") if err != nil { diff --git a/internal/submodule/git/provider_test.go b/internal/submodule/git/provider_test.go index a37a67d..003d1fd 100644 --- a/internal/submodule/git/provider_test.go +++ b/internal/submodule/git/provider_test.go @@ -3,12 +3,14 @@ package git import ( "context" "errors" + "fmt" "os" "os/exec" "path/filepath" "runtime" "slices" "testing" + "time" "submarine/internal/submodule" ) @@ -66,7 +68,7 @@ func TestTree(t *testing.T) { submodule.Walk(repo.Submodules, func(s *submodule.Submodule) { paths = append(paths, s.Path) }) want := []string{ "src/core", "src/ui", "src/net", "libs/store", "libs/log", "libs/log/modules/nested", - "libs/feature", "libs/conflict", "libs/staged", "libs/missing", "vendor", + "libs/feature", "libs/conflict", "libs/staged", "libs/missing", "vendor", "libs/work", } if !slices.Equal(paths, want) { t.Errorf("paths = %v\nwant %v", paths, want) @@ -101,6 +103,7 @@ func TestStatus(t *testing.T) { {path: "libs/staged", ref: "origin/main", source: submodule.RefFromCurrentBranch, staged: true}, {path: "libs/missing", issues: []submodule.IssueKind{submodule.IssueMissing}}, {path: "vendor", issues: []submodule.IssueKind{submodule.IssueChanged}, ref: "origin/main", source: submodule.RefFromCurrentBranch, behind: 2}, + {path: "libs/work", issues: []submodule.IssueKind{submodule.IssueChanged}, ref: "origin/main", source: submodule.RefFromCurrentBranch}, } for _, tt := range tests { st, err := p.Status(context.Background(), tt.path) @@ -139,6 +142,92 @@ func TestStatus(t *testing.T) { } } +func TestChanges(t *testing.T) { + p, _ := loadFixture(t) + ctx := context.Background() + + st, err := p.Status(ctx, "libs/work") + if err != nil { + t.Fatal(err) + } + c := st.Changes + if c == nil { + t.Fatal("libs/work: Changes = nil") + } + want := []submodule.FileChange{ + {Path: "main.go", Kind: submodule.ChangeModified, Added: 6, Removed: 2}, + {Path: "util.go", Kind: submodule.ChangeModified, Added: 1}, + {Path: "logo.png", Kind: submodule.ChangeModified, Binary: true}, + {Path: "notes_new.go", Kind: submodule.ChangeAdded, Added: 1}, + {Path: "old.go", Kind: submodule.ChangeRemoved, Removed: 8}, + {Path: "lib2.go", OrigPath: "lib.go", Kind: submodule.ChangeRenamed}, + {Path: "scratch.txt", Kind: submodule.ChangeUntracked}, + {Path: "tmp/", Kind: submodule.ChangeUntracked}, + } + for i, f := range c.Files { + if f.Modified.IsZero() != (f.Kind == submodule.ChangeRemoved) { + t.Errorf("libs/work: %s: Modified %v", f.Path, f.Modified) + } + c.Files[i].Modified = time.Time{} + } + if !slices.Equal(c.Files, want) { + t.Errorf("libs/work: Files =\n%+v\nwant\n%+v", c.Files, want) + } + if c.Total != 8 || c.Added != 8 || c.Removed != 10 { + t.Errorf("libs/work: Total %d, +%d -%d; want 8, +8 -10", c.Total, c.Added, c.Removed) + } + if n := c.Counts(nil); n[submodule.ChangeModified] != 3 || n[submodule.ChangeUntracked] != 2 { + t.Errorf("libs/work: Counts = %v", n) + } + + for _, path := range []string{"libs/log", "src/ui"} { + st, err := p.Status(ctx, path) + if err != nil { + t.Fatal(err) + } + if st.Changes != nil { + t.Errorf("%s: clean submodule has Changes %+v", path, st.Changes) + } + } + + st, err = p.Status(ctx, "libs/conflict") + if err != nil { + t.Fatal(err) + } + if st.Changes == nil || len(st.Changes.Files) != 1 || st.Changes.Files[0].Kind != submodule.ChangeConflicted { + t.Errorf("libs/conflict: Changes = %+v", st.Changes) + } +} + +func TestChangesCap(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not available") + } + dir := t.TempDir() + for _, args := range [][]string{{"init", "-q"}, {"-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q", "--allow-empty", "-m", "init"}} { + if out, err := exec.Command("git", append([]string{"-C", dir}, args...)...).CombinedOutput(); err != nil { + t.Fatalf("git %v: %v\n%s", args, err, out) + } + } + n := submodule.MaxChangeFiles + 5 + for i := range n { + if err := os.WriteFile(filepath.Join(dir, fmt.Sprintf("f%03d.txt", i)), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + } + out, err := run(context.Background(), dir, "status", "--porcelain=v2", "-z", "--untracked-files=normal") + if err != nil { + t.Fatal(err) + } + c, err := changes(context.Background(), dir, parseStatus(out)) + if err != nil { + t.Fatal(err) + } + if c.Total != n || len(c.Files) != submodule.MaxChangeFiles || c.Files[0].Path != "f000.txt" { + t.Errorf("Total %d, len %d, first %q; want %d, %d, f000.txt", c.Total, len(c.Files), c.Files[0].Path, n, submodule.MaxChangeFiles) + } +} + func TestStatusMissingPresence(t *testing.T) { p, _ := loadFixture(t) st, err := p.Status(context.Background(), "libs/missing") diff --git a/internal/submodule/mock/mock.go b/internal/submodule/mock/mock.go index 73e9a7a..a6e1db3 100644 --- a/internal/submodule/mock/mock.go +++ b/internal/submodule/mock/mock.go @@ -95,6 +95,24 @@ func inSync(ref string, c *submodule.Commit) *submodule.Status { } } +// changes builds a Changes value like the git provider: sorted, with +// totals. The last file was edited age ago, earlier ones increasingly +// longer ago. +func changes(age time.Duration, files []submodule.FileChange) *submodule.Changes { + c := &submodule.Changes{Files: files, Total: len(files)} + for i := range files { + f := &files[i] + if f.Kind != submodule.ChangeRemoved { + n := time.Duration(len(files) - 1 - i) + f.Modified = time.Now().Add(-age - n*n*90*time.Minute) + } + c.Added += f.Added + c.Removed += f.Removed + } + c.Sort() + return c +} + func fixture() []node { c1 := commit("3f9a1c2", "Bump golangci-lint to v2", "anna", 26*time.Hour) c2 := commit("a1b2c3d", "Initial import", "ben", 40*24*time.Hour) @@ -104,6 +122,23 @@ func fixture() []node { srcSub2 := inSync("origin/main", commit("77aa88b", "Merge PR #42", "dana", 5*time.Hour)) srcSub2.Worktree = submodule.WorktreeModified + srcSub2.Changes = changes(20*time.Minute, []submodule.FileChange{ + {Path: "internal/client/retry.go", Kind: submodule.ChangeModified, Added: 84, Removed: 12}, + {Path: "internal/client/backoff.go", Kind: submodule.ChangeModified, Added: 41, Removed: 3}, + {Path: "internal/server/handler.go", Kind: submodule.ChangeModified, Added: 17, Removed: 17}, + {Path: "README.md", Kind: submodule.ChangeModified, Added: 6, Removed: 2}, + {Path: "go.mod", Kind: submodule.ChangeModified, Added: 2, Removed: 2}, + {Path: "go.sum", Kind: submodule.ChangeModified, Added: 9, Removed: 4}, + {Path: "Makefile", Kind: submodule.ChangeModified, Added: 1, Removed: 1}, + {Path: "internal/client/retry_test.go", Kind: submodule.ChangeAdded, Added: 63}, + {Path: "docs/design/backoff-notes.md", Kind: submodule.ChangeAdded, Added: 28}, + {Path: "internal/legacy/old_client.go", Kind: submodule.ChangeRemoved, Removed: 212}, + {Path: "assets/logo.png", Kind: submodule.ChangeModified, Binary: true}, + {Path: "internal/client/transport.go", OrigPath: "internal/net/transport.go", Kind: submodule.ChangeRenamed, Added: 5, Removed: 1}, + {Path: "scratch.txt", Kind: submodule.ChangeUntracked}, + {Path: "tmp/", Kind: submodule.ChangeUntracked}, + {Path: "profile.out", Kind: submodule.ChangeUntracked}, + }) subSubPin := commit("4e5f6a7", "Add feature flag", "eve", 30*time.Hour) subSubTip := commit("9f8e7d6", "Fix login timeout", "anna", 2*time.Hour) @@ -118,6 +153,10 @@ func fixture() []node { schema := inSync("origin/main", commit("1a2b3c4", "Add v3 schema", "ben", 6*24*time.Hour)) schema.Worktree = submodule.WorktreeUntracked + schema.Changes = changes(40*time.Minute, []submodule.FileChange{ + {Path: "v3/user.schema.json", Kind: submodule.ChangeUntracked}, + {Path: "v3/order.schema.json", Kind: submodule.ChangeUntracked}, + }) unpushed := commit("b7c8d9e", "Hotfix null pointer in parser", "you", 20*time.Minute) sub3Tip := commit("6f5e4d3", "Release 2.1.0", "chris", 4*24*time.Hour) diff --git a/internal/submodule/model.go b/internal/submodule/model.go index d2fa28f..6508567 100644 --- a/internal/submodule/model.go +++ b/internal/submodule/model.go @@ -2,7 +2,12 @@ // (recursively nested) git submodules. It has no UI dependencies. package submodule -import "time" +import ( + "cmp" + "slices" + "strings" + "time" +) // Repo is the superproject. type Repo struct { @@ -100,6 +105,68 @@ type Commit struct { When time.Time } +// ChangeKind classifies one changed file. The order is the display order. +type ChangeKind int + +const ( + ChangeConflicted ChangeKind = iota + ChangeModified + ChangeAdded + ChangeRemoved + ChangeRenamed + ChangeUntracked + NumChangeKinds +) + +// MaxChangeFiles limits Changes.Files; Changes.Total keeps the real count. +const MaxChangeFiles = 200 + +// FileChange is one changed file, relative to HEAD of the submodule +// (staged and unstaged changes are combined). +type FileChange struct { + Path string + OrigPath string // old path, only for renames + Kind ChangeKind + Added int // added lines; 0 for untracked, binary and nested submodules + Removed int // removed lines + Binary bool // line counts are unknown + Submodule bool // a nested submodule pointer changed + Modified time.Time // modification time of the file; zero if it doesn't exist +} + +// Changes describes what differs in the worktree of a submodule. +type Changes struct { + Files []FileChange // at most MaxChangeFiles, sorted by kind, then by changed lines + Total int // all changed files; may exceed len(Files) + Added int // added lines in all files + Removed int // removed lines in all files +} + +// Counts returns the number of files per kind among files (all of +// c.Files if files is nil). +func (c *Changes) Counts(files []FileChange) [NumChangeKinds]int { + if files == nil && c != nil { + files = c.Files + } + var n [NumChangeKinds]int + for _, f := range files { + n[f.Kind]++ + } + return n +} + +// Sort orders Files by kind, then by changed lines (largest first), then by +// path. +func (c *Changes) Sort() { + slices.SortStableFunc(c.Files, func(a, b FileChange) int { + return cmp.Or( + cmp.Compare(a.Kind, b.Kind), + cmp.Compare(b.Added+b.Removed, a.Added+a.Removed), + strings.Compare(a.Path, b.Path), + ) + }) +} + // Status is the full state of one submodule. type Status struct { Presence Presence @@ -111,4 +178,5 @@ type Status struct { CheckedOut *Commit Tip *Commit Between []Commit // commits that make up the pin <-> tip difference + Changes *Changes // nil if the worktree is clean or the details are unknown } diff --git a/internal/tui/detail/changes.go b/internal/tui/detail/changes.go new file mode 100644 index 0000000..d74a12a --- /dev/null +++ b/internal/tui/detail/changes.go @@ -0,0 +1,234 @@ +package detail + +import ( + "fmt" + "path" + "strings" + + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" + + "submarine/internal/submodule" + "submarine/internal/tui/theme" +) + +// previewBudget is the number of file lines shown in the change preview. +const previewBudget = 10 + +// pickPreview chooses which files of a sorted change list to show. Every +// kind that occurs gets at least one line; the remaining lines go to the +// files in sort order. Both results keep the sort order. A single hidden +// file would cost a "1 more" line, so it is shown instead. +func pickPreview(files []submodule.FileChange, budget int) (shown, hidden []submodule.FileChange) { + if len(files) <= budget+1 { + return files, nil + } + pick := make([]bool, len(files)) + var seen [submodule.NumChangeKinds]bool + left := budget + for i, f := range files { + if !seen[f.Kind] && left > 0 { + seen[f.Kind], pick[i] = true, true + left-- + } + } + for i := range files { + if left > 0 && !pick[i] { + pick[i] = true + left-- + } + } + for i, f := range files { + if pick[i] { + shown = append(shown, f) + } else { + hidden = append(hidden, f) + } + } + return shown, hidden +} + +// changeLines renders the change preview: a summary line and a flat list of +// files, a colored letter in front of each. +func (m Model) changeLines(c *submodule.Changes, label func(string) string) []string { + t := m.theme + muted, text := theme.Fg(t.Muted), theme.Fg(t.Text) + + plural := "files" + if c.Total == 1 { + plural = "file" + } + // summary over all files: count, kinds, lines + parts := []string{text.Render(fmt.Sprintf("%d %s", c.Total, plural))} + if k := m.kindCounts(c.Counts(nil)); k != "" { + parts = append(parts, k) + } + sep := muted.Render(" · ") + if c.Added+c.Removed > 0 { + // line totals are the least important part: drop them if they don't fit + if withLines := append(parts, m.lineCounts(c.Added, c.Removed)); labelWidth+lipgloss.Width(strings.Join(withLines, sep)) <= m.width { + parts = withLines + } + } + out := []string{label("Changes") + strings.Join(parts, sep)} + + shown, hidden := pickPreview(c.Files, previewBudget) + files := m.fileLines(shown) + + // a rule under the summary, as wide as the summary or the file table + ruleW := lipgloss.Width(out[0]) - labelWidth + for _, l := range files { + ruleW = max(ruleW, lipgloss.Width(l)-labelWidth) + } + ruleW = min(ruleW, m.width-labelWidth) + out = append(out, label("")+muted.Render(strings.Repeat(m.theme.Glyphs.Rule, ruleW))) + out = append(out, files...) + if more := c.Total - len(shown); more > 0 { + line := label("") + muted.Render(fmt.Sprintf("… %d more", more)) + if k := m.kindCounts(c.Counts(hidden)); k != "" { + line += muted.Render(" (") + k + muted.Render(")") + } + out = append(out, line) + } + return out +} + +// kindCounts renders "2M 1A 1?", each in the color of its kind. +func (m Model) kindCounts(n [submodule.NumChangeKinds]int) string { + var parts []string + for k := submodule.ChangeKind(0); k < submodule.NumChangeKinds; k++ { + if n[k] > 0 { + parts = append(parts, m.theme.ChangeStyle(k, lipgloss.NewStyle()).Render(fmt.Sprintf("%d%s", n[k], theme.ChangeLetter(k)))) + } + } + return strings.Join(parts, " ") +} + +// lineCounts renders "+20 −4"; zero parts are omitted. +func (m Model) lineCounts(added, removed int) string { + t := m.theme + var parts []string + if added > 0 { + parts = append(parts, theme.Fg(t.Added).Render(fmt.Sprintf("+%d", added))) + } + if removed > 0 { + parts = append(parts, theme.Fg(t.Danger).Render(fmt.Sprintf("%s%d", t.Glyphs.Minus, removed))) + } + return strings.Join(parts, " ") +} + +// maxPathWidth caps the path column, so the counts stay close to the names +// in wide panes; longer paths have their directory cut. +const maxPathWidth = 48 + +// minPathWidth is the path width below which the age column is dropped. +const minPathWidth = 16 + +// fileRow is one file of the preview, split into its columns. +type fileRow struct { + kind submodule.ChangeKind + name, prefix, dir string // prefix ("← ") is kept when dir is cut + counts string // rendered + age string // e.g. "19m ago"; empty for removed files +} + +func (r fileRow) pathWidth() int { + w := lipgloss.Width(r.name) + if r.dir != "" { + w += 2 + lipgloss.Width(r.prefix) + lipgloss.Width(r.dir) + } + return w +} + +// fileLines renders the files as a table in the value column: +// +// M name dir/ +20 −4 19m ago +// +// The path column is as wide as the widest path (up to maxPathWidth and the +// pane width); counts are right-aligned next to it, followed by the age. If +// space is short the age column goes first, then the directory is cut from +// the left, the name last. +func (m Model) fileLines(files []submodule.FileChange) []string { + t := m.theme + muted, text := theme.Fg(t.Muted), theme.Fg(t.Text) + + rows := make([]fileRow, len(files)) + pathW, countsW, ageW := 0, 0, 0 + for i, f := range files { + r := fileRow{kind: f.Kind} + r.name, r.dir = splitPath(f.Path) + if f.OrigPath != "" { + r.prefix, r.dir = t.Glyphs.From+" ", f.OrigPath + } + switch { + case f.Submodule: + r.counts = muted.Render("submodule") + case f.Binary: + r.counts = muted.Render("bin") + default: + r.counts = m.lineCounts(f.Added, f.Removed) + } + if !f.Modified.IsZero() { + r.age = theme.Ago(f.Modified) + } + rows[i] = r + pathW = max(pathW, r.pathWidth()) + countsW = max(countsW, lipgloss.Width(r.counts)) + ageW = max(ageW, lipgloss.Width(r.age)) + } + + avail := m.width - labelWidth - 3 // letter and its gap + if countsW > 0 { + avail -= countsW + 2 + } + if ageW > 0 && avail-ageW-2 < min(pathW, minPathWidth) { + ageW = 0 // too narrow: drop the age column + } + if ageW > 0 { + avail -= ageW + 2 + } + pathW = max(min(pathW, maxPathWidth, avail), 1) + + out := make([]string, len(rows)) + for i, r := range rows { + name := ansi.Truncate(r.name, pathW, "…") + dir := "" + if room := pathW - lipgloss.Width(name) - 2 - lipgloss.Width(r.prefix); room >= 2 && r.dir != "" { + dir = r.dir + if w := lipgloss.Width(dir); w > room { + dir = ansi.TruncateLeft(dir, w-(room-1), "…") + } + dir = r.prefix + dir + } + + path := text.Bold(true).Render(name) + if dir != "" { + path += " " + muted.Render(dir) + } + line := strings.Repeat(" ", labelWidth) + + t.ChangeStyle(r.kind, lipgloss.NewStyle()).Render(theme.ChangeLetter(r.kind)) + " " + path + line += strings.Repeat(" ", pathW-lipgloss.Width(path)) + if countsW > 0 { + line += strings.Repeat(" ", 2+countsW-lipgloss.Width(r.counts)) + r.counts + } + if ageW > 0 && r.age != "" { + line += " " + muted.Render(r.age) + } + out[i] = strings.TrimRight(line, " ") + } + return out +} + +// splitPath returns the base name and the directory with a trailing slash. +// A path ending in a slash (an untracked directory) keeps it on the name. +func splitPath(p string) (name, dir string) { + suffix := "" + if strings.HasSuffix(p, "/") { + p, suffix = strings.TrimSuffix(p, "/"), "/" + } + name = path.Base(p) + suffix + if d := path.Dir(p); d != "." { + dir = d + "/" + } + return name, dir +} diff --git a/internal/tui/detail/changes_test.go b/internal/tui/detail/changes_test.go new file mode 100644 index 0000000..b4ea3cd --- /dev/null +++ b/internal/tui/detail/changes_test.go @@ -0,0 +1,65 @@ +package detail + +import ( + "fmt" + "testing" + + "submarine/internal/submodule" +) + +func files(kind submodule.ChangeKind, n int) []submodule.FileChange { + var fs []submodule.FileChange + for i := range n { + fs = append(fs, submodule.FileChange{Path: fmt.Sprintf("%d-%d", kind, i), Kind: kind}) + } + return fs +} + +func TestPickPreview(t *testing.T) { + t.Run("fits", func(t *testing.T) { + all := append(files(submodule.ChangeModified, 2), files(submodule.ChangeUntracked, 1)...) + shown, hidden := pickPreview(all, 5) + if len(shown) != 3 || len(hidden) != 0 { + t.Errorf("shown %d, hidden %d; want 3, 0", len(shown), len(hidden)) + } + }) + + t.Run("one over the budget shows all", func(t *testing.T) { + shown, hidden := pickPreview(files(submodule.ChangeModified, 6), 5) + if len(shown) != 6 || len(hidden) != 0 { + t.Errorf("shown %d, hidden %d; want 6, 0", len(shown), len(hidden)) + } + }) + + t.Run("every kind keeps a line", func(t *testing.T) { + all := files(submodule.ChangeModified, 8) + all = append(all, files(submodule.ChangeRemoved, 1)...) + all = append(all, files(submodule.ChangeUntracked, 3)...) + shown, hidden := pickPreview(all, 4) + if len(shown) != 4 || len(hidden) != 8 { + t.Fatalf("shown %d, hidden %d; want 4, 8", len(shown), len(hidden)) + } + want := []string{"1-0", "1-1", "3-0", "5-0"} // sort order is kept + for i, f := range shown { + if f.Path != want[i] { + t.Errorf("shown[%d] = %q, want %q (all: %+v)", i, f.Path, want[i], shown) + } + } + if n := (&submodule.Changes{}).Counts(hidden); n[submodule.ChangeModified] != 6 || n[submodule.ChangeUntracked] != 2 { + t.Errorf("hidden counts = %v", n) + } + }) +} + +func TestSplitPath(t *testing.T) { + for _, tt := range []struct{ in, name, dir string }{ + {"README.md", "README.md", ""}, + {"src/a/b.go", "b.go", "src/a/"}, + {"tmp/", "tmp/", ""}, + {"src/tmp/", "tmp/", "src/"}, + } { + if name, dir := splitPath(tt.in); name != tt.name || dir != tt.dir { + t.Errorf("splitPath(%q) = %q, %q; want %q, %q", tt.in, name, dir, tt.name, tt.dir) + } + } +} diff --git a/internal/tui/detail/detail.go b/internal/tui/detail/detail.go index 2442709..5e08c68 100644 --- a/internal/tui/detail/detail.go +++ b/internal/tui/detail/detail.go @@ -82,6 +82,10 @@ func (m Model) lines(s *submodule.Submodule) []string { if st.Checkout == submodule.CheckoutDetached { out = append(out, label("")+muted.Render("detached HEAD on pin")) } + if st.Changes != nil { + out = append(out, "") + out = append(out, m.changeLines(st.Changes, label)...) + } out = append(out, "") // ref diff --git a/internal/tui/theme/issues.go b/internal/tui/theme/issues.go index 2773af2..2aa0c7b 100644 --- a/internal/tui/theme/issues.go +++ b/internal/tui/theme/issues.go @@ -25,6 +25,42 @@ func IssueWord(k submodule.IssueKind) string { } } +// ChangeLetter is the one-letter label of a changed file. +func ChangeLetter(k submodule.ChangeKind) string { + switch k { + case submodule.ChangeConflicted: + return "!" + case submodule.ChangeModified: + return "M" + case submodule.ChangeAdded: + return "A" + case submodule.ChangeRemoved: + return "D" + case submodule.ChangeRenamed: + return "R" + default: + return "?" + } +} + +// ChangeStyle returns base with the color of a change kind. +func (t Theme) ChangeStyle(k submodule.ChangeKind, base lipgloss.Style) lipgloss.Style { + switch k { + case submodule.ChangeConflicted: + return base.Foreground(t.Danger).Bold(true) + case submodule.ChangeModified: + return base.Foreground(t.Warn) + case submodule.ChangeAdded: + return base.Foreground(t.Added) + case submodule.ChangeRemoved: + return base.Foreground(t.Danger) + case submodule.ChangeRenamed: + return base.Foreground(t.Primary) + default: + return base.Foreground(t.Muted) + } +} + // LevelStyle returns base with the text color (and weight) for a level. func (t Theme) LevelStyle(l submodule.Level, base lipgloss.Style) lipgloss.Style { switch l { diff --git a/internal/tui/theme/theme.go b/internal/tui/theme/theme.go index 2708fe6..7725f2f 100644 --- a/internal/tui/theme/theme.go +++ b/internal/tui/theme/theme.go @@ -13,6 +13,7 @@ import ( type Theme struct { Primary lipgloss.TerminalColor // accent: branch names, commit hashes Info lipgloss.TerminalColor + Added lipgloss.TerminalColor // added lines and files Warn lipgloss.TerminalColor Danger lipgloss.TerminalColor Muted lipgloss.TerminalColor @@ -31,6 +32,7 @@ func Default() Theme { return Theme{ Primary: lipgloss.Color("6"), // cyan Info: lipgloss.Color("4"), // blue + Added: lipgloss.Color("2"), // green Warn: lipgloss.Color("3"), // yellow Danger: lipgloss.Color("1"), // red Muted: lipgloss.Color("8"), // bright black / grey @@ -49,17 +51,17 @@ func Fg(c lipgloss.TerminalColor) lipgloss.Style { // Glyphs are the few symbols used next to the state words. type Glyphs struct { - Staged, Ahead, Behind, Collapsed, Expanded, Problems string + Staged, Ahead, Behind, Collapsed, Expanded, Problems, Minus, From, Rule string } func Unicode() Glyphs { return Glyphs{ - Staged: "●", Ahead: "↑", Behind: "↓", Collapsed: "▸", Expanded: "▾", Problems: "·", + Staged: "●", Ahead: "↑", Behind: "↓", Collapsed: "▸", Expanded: "▾", Problems: "·", Minus: "−", From: "←", Rule: "─", } } func ASCII() Glyphs { return Glyphs{ - Staged: "*", Ahead: "^", Behind: "v", Collapsed: ">", Expanded: "v", Problems: ".", + Staged: "*", Ahead: "^", Behind: "v", Collapsed: ">", Expanded: "v", Problems: ".", Minus: "-", From: "<-", Rule: "-", } } diff --git a/scripts/make-fixture.sh b/scripts/make-fixture.sh index b801c6a..707346b 100755 --- a/scripts/make-fixture.sh +++ b/scripts/make-fixture.sh @@ -42,7 +42,7 @@ upstream() { } mkdir -p "$root/remotes" "$root/work" -for r in core ui net store log feature nested; do remote "$r"; done +for r in core ui net store log feature nested work; do remote "$r"; done # "feature" has a branch that main doesn't contain g "$root/work/feature" switch -c feat/search @@ -57,6 +57,17 @@ commit "$root/work/store" dev "Start develop" g "$root/work/store" push origin develop g "$root/work/store" switch main +# "work" has several tracked files, to show every kind of change +w="$root/work/work" +seq 1 20 | sed 's/^/line /' >"$w/main.go" +seq 1 3 | sed 's/^/util /' >"$w/util.go" +seq 1 8 | sed 's/^/old /' >"$w/old.go" +seq 1 5 | sed 's/^/lib /' >"$w/lib.go" +printf '\000\001\002\003' >"$w/logo.png" +g "$w" add . +g "$w" commit -m "Add sources" +g "$w" push origin main + # "nested" becomes a submodule of "log" g "$root/work/log" submodule add "$root/remotes/nested.git" modules/nested g "$root/work/log" commit -m "Add nested" @@ -76,6 +87,7 @@ add core libs/conflict add ui libs/staged add net libs/missing add ui vendor +add work libs/work g "$super" submodule update --init --recursive g "$super" commit -m "Add submodules" @@ -106,6 +118,16 @@ echo dirty >>"$super/libs/log/modules/nested/README" # vendor: untracked file echo scratch >"$super/vendor/scratch.txt" +# libs/work: one of every kind of change +wk="$super/libs/work" +{ seq 3 20 | sed 's/^/line /'; seq 1 6 | sed 's/^/new /'; } >"$wk/main.go" # modified +6 -2 +echo "util 4" >>"$wk/util.go" # modified +1 +printf '\000\001\002\003\004' >"$wk/logo.png" # modified, binary +echo "package work" >"$wk/notes_new.go"; g "$wk" add notes_new.go # added (staged) +rm "$wk/old.go" # removed +g "$wk" mv lib.go lib2.go # renamed (staged) +echo scratch >"$wk/scratch.txt"; mkdir "$wk/tmp"; echo x >"$wk/tmp/a.txt" # untracked + # libs/staged: new pin staged in the superproject, not committed g "$super/libs/staged" merge --ff-only origin/main g "$super" add libs/staged