This repository has been archived on 2026-10-25. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
submarine/internal/submodule/git/parse.go
T
Simon FrankenandClaude Opus 5.5 19c6f17eb2 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>
2026-10-21 15:55:46 +02:00

222 lines
5.6 KiB
Go

package git
import (
"fmt"
"strconv"
"strings"
"time"
"submarine/internal/submodule"
)
// gitmodule is one entry of a .gitmodules file.
type gitmodule struct {
name, path, url, branch string
}
// parseGitmodules parses the output of
// `git config -f .gitmodules --get-regexp '^submodule\..*\.(path|url|branch)$'`.
// Entries keep the order of first appearance; entries without a path are
// dropped.
func parseGitmodules(out string) []gitmodule {
var order []string
byName := map[string]*gitmodule{}
for _, line := range strings.Split(out, "\n") {
key, value, _ := strings.Cut(line, " ")
key, ok := strings.CutPrefix(key, "submodule.")
if !ok {
continue
}
dot := strings.LastIndexByte(key, '.')
if dot < 0 {
continue
}
name, attr := key[:dot], key[dot+1:]
g := byName[name]
if g == nil {
g = &gitmodule{name: name}
byName[name] = g
order = append(order, name)
}
switch attr {
case "path":
g.path = value
case "url":
g.url = value
case "branch":
g.branch = value
}
}
var mods []gitmodule
for _, name := range order {
if g := byName[name]; g.path != "" {
mods = append(mods, *g)
}
}
return mods
}
// 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 _, e := range es {
switch {
case e.kind == submodule.ChangeConflicted:
return submodule.WorktreeConflict
case e.tracked:
w = submodule.WorktreeModified
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)
if len(f) != 2 {
return 0, 0, fmt.Errorf("unexpected rev-list output %q", out)
}
if left, err = strconv.Atoi(f[0]); err != nil {
return 0, 0, err
}
if right, err = strconv.Atoi(f[1]); err != nil {
return 0, 0, err
}
return left, right, nil
}
// commitFormat is the `git log --format` matching parseCommits: fields
// separated by US, records terminated by RS.
const commitFormat = "--format=%h%x1f%s%x1f%an%x1f%ct%x1e"
// parseCommits parses `git log` output produced with commitFormat.
func parseCommits(out string) []submodule.Commit {
var cs []submodule.Commit
for _, rec := range strings.Split(out, "\x1e") {
f := strings.Split(strings.TrimLeft(rec, "\n"), "\x1f")
if len(f) != 4 {
continue
}
c := submodule.Commit{SHA: f[0], Subject: f[1], Author: f[2]}
if sec, err := strconv.ParseInt(f[3], 10, 64); err == nil {
c.When = time.Unix(sec, 0)
}
cs = append(cs, c)
}
return cs
}
// parseGitlink returns the SHA of a gitlink (mode 160000) entry in
// `git ls-files -s` or `git ls-tree` output, or "" if there is none.
func parseGitlink(out string) string {
for _, line := range strings.Split(out, "\n") {
f := strings.Fields(line)
switch {
case len(f) >= 3 && f[0] == "160000" && f[1] == "commit": // ls-tree
return f[2]
case len(f) >= 2 && f[0] == "160000": // ls-files -s
return f[1]
}
}
return ""
}