Archived
Preview uncommitted changes in the detail pane
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
64fe34b5b0
commit
19c6f17eb2
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user