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
@@ -15,16 +15,16 @@ internal/submodule/ domain model; NO UI imports
|
|||||||
provider.go Provider interface
|
provider.go Provider interface
|
||||||
internal/submodule/git/ read-only provider using the git CLI
|
internal/submodule/git/ read-only provider using the git CLI
|
||||||
exec.go git runner (environment, errors)
|
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
|
provider.go Tree, Status, pin → ref resolution
|
||||||
internal/submodule/mock/ fixture provider with artificial latency
|
internal/submodule/mock/ fixture provider with artificial latency
|
||||||
internal/tui/ Bubble Tea application
|
internal/tui/ Bubble Tea application
|
||||||
app.go root model: layout, message routing, loading
|
app.go root model: layout, message routing, loading
|
||||||
keys.go key bindings (also feed the help view)
|
keys.go key bindings (also feed the help view)
|
||||||
msgs.go treeLoadedMsg, statusLoadedMsg
|
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
|
tree/ tree component: rows, navigation, mouse, view
|
||||||
detail/ detail pane
|
detail/ detail pane; changes.go: change preview
|
||||||
header/ header line
|
header/ header line
|
||||||
footer/ help and flash messages
|
footer/ help and flash messages
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -128,3 +128,21 @@ processes.
|
|||||||
**Decision.** The git provider limits concurrent `Status` calls to
|
**Decision.** The git provider limits concurrent `Status` calls to
|
||||||
`runtime.NumCPU()` with a semaphore that respects `ctx`.
|
`runtime.NumCPU()` with a semaphore that respects `ctx`.
|
||||||
**Consequences.** The UI stays unchanged; rows fill in steadily.
|
**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.
|
||||||
|
|||||||
@@ -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)
|
- **F22** Lists the commits between pin and tip (SHA, subject, author, age)
|
||||||
with a title stating ahead/behind/diverged.
|
with a title stating ahead/behind/diverged.
|
||||||
- **F23** Shows child count, issues below, and the remote URL.
|
- **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
|
### Header and footer
|
||||||
|
|
||||||
|
|||||||
+27
-1
@@ -32,10 +32,29 @@ in several at once.
|
|||||||
| `Pin` | `Ref`, `Source`, `Ahead`, `Behind`, `Pushed` |
|
| `Pin` | `Ref`, `Source`, `Ahead`, `Behind`, `Pushed` |
|
||||||
| `Pinned`, `CheckedOut`, `Tip` | Commits (SHA, subject, author, time) |
|
| `Pinned`, `CheckedOut`, `Tip` | Commits (SHA, subject, author, time) |
|
||||||
| `Between` | Commits that make up the pin ↔ tip difference |
|
| `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
|
`Status == nil` means "still loading". `Submodule.Err` is set if loading
|
||||||
failed.
|
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
|
## Issues: summarizing the dimensions
|
||||||
|
|
||||||
The UI never shows raw dimensions in the tree. `Status.Issues()` summarizes
|
The UI never shows raw dimensions in the tree. `Status.Issues()` summarizes
|
||||||
@@ -107,7 +126,9 @@ are local; `<pin>` is the index gitlink.
|
|||||||
| `Pinned`, `PinStaged` | parent: `ls-files -s -- <path>` vs `ls-tree HEAD -- <path>` |
|
| `Pinned`, `PinStaged` | parent: `ls-files -s -- <path>` vs `ls-tree HEAD -- <path>` |
|
||||||
| `Presence` | directory missing → `DirMissing`; no `.git` in it → `NotInitialized` |
|
| `Presence` | directory missing → `DirMissing`; no `.git` in it → `NotInitialized` |
|
||||||
| `Checkout` | `rev-parse HEAD`, `symbolic-ref -q HEAD` (no branch → detached) |
|
| `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 <pin> refs/remotes` is not empty |
|
| `Pushed` | `for-each-ref --contains <pin> refs/remotes` is not empty |
|
||||||
| `Pin.Ref` | rules above; the remote is `origin`, else the first remote |
|
| `Pin.Ref` | rules above; the remote is `origin`, else the first remote |
|
||||||
| `Ahead`, `Behind` | `rev-list --left-right --count <pin>...<ref>` |
|
| `Ahead`, `Behind` | `rev-list --left-right --count <pin>...<ref>` |
|
||||||
@@ -123,6 +144,11 @@ Details:
|
|||||||
- Rule 6 shows the short SHA; there is no tip and no distance.
|
- Rule 6 shows the short SHA; there is no tip and no distance.
|
||||||
- `--ignore-submodules=dirty`: changes inside a nested submodule do not make
|
- `--ignore-submodules=dirty`: changes inside a nested submodule do not make
|
||||||
its parent `changed` (D7), but a moved nested pin does.
|
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
|
- A missing submodule only gets a `Pinned` SHA; nested submodules of an
|
||||||
uninitialized submodule are unknown and not shown.
|
uninitialized submodule are unknown and not shown.
|
||||||
- If the pinned commit doesn't exist in the submodule (not fetched), loading
|
- If the pinned commit doesn't exist in the submodule (not fetched), loading
|
||||||
|
|||||||
+50
-4
@@ -61,12 +61,58 @@ indent ▸ name [n] status ref delta
|
|||||||
2. One line per issue: word (colored, 10 columns) + explanation;
|
2. One line per issue: word (colored, 10 columns) + explanation;
|
||||||
`in sync nothing to do` if there are none; `staged` and
|
`in sync nothing to do` if there are none; `staged` and
|
||||||
`detached HEAD on pin` as information
|
`detached HEAD on pin` as information
|
||||||
3. `Ref` with distance and `via <rule>`
|
3. `Changes` (only with uncommitted changes), see
|
||||||
4. `Pinned`, `Checkout`, `Tip`: SHA + subject; the checkout SHA is yellow if it
|
[Change preview](#change-preview)
|
||||||
|
4. `Ref` with distance and `via <rule>`
|
||||||
|
5. `Pinned`, `Checkout`, `Tip`: SHA + subject; the checkout SHA is yellow if it
|
||||||
differs from the pin
|
differs from the pin
|
||||||
5. "Pin is n commits behind/ahead of …" / "Pin and … have diverged": commit list
|
6. "Pin is n commits behind/ahead of …" / "Pin and … have diverged": commit list
|
||||||
6. `Children` (count and issues below), `URL`
|
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 (<kinds>)` 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
|
### Header
|
||||||
|
|
||||||
`path branch ↑↓ · n submodules · <count> <issue>… · n pin staged · n loading · fetched <age>`
|
`path branch ↑↓ · n submodules · <count> <issue>… · n pin staged · n loading · fetched <age>`
|
||||||
|
|||||||
@@ -56,23 +56,119 @@ func parseGitmodules(out string) []gitmodule {
|
|||||||
return mods
|
return mods
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseWorktree summarizes `git status --porcelain=v2` output. A conflict
|
// entry is one changed path of `git status --porcelain=v2 -z`.
|
||||||
// outranks modifications, which outrank untracked files.
|
type entry struct {
|
||||||
func parseWorktree(out string) submodule.Worktree {
|
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
|
w := submodule.WorktreeClean
|
||||||
for _, line := range strings.Split(out, "\n") {
|
for _, e := range es {
|
||||||
switch {
|
switch {
|
||||||
case strings.HasPrefix(line, "u "):
|
case e.kind == submodule.ChangeConflicted:
|
||||||
return submodule.WorktreeConflict
|
return submodule.WorktreeConflict
|
||||||
case strings.HasPrefix(line, "1 "), strings.HasPrefix(line, "2 "):
|
case e.tracked:
|
||||||
w = submodule.WorktreeModified
|
w = submodule.WorktreeModified
|
||||||
case strings.HasPrefix(line, "? ") && w == submodule.WorktreeClean:
|
case w == submodule.WorktreeClean:
|
||||||
w = submodule.WorktreeUntracked
|
w = submodule.WorktreeUntracked
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return w
|
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`.
|
// parseLeftRight parses `git rev-list --left-right --count a...b`.
|
||||||
func parseLeftRight(out string) (left, right int, err error) {
|
func parseLeftRight(out string) (left, right int, err error) {
|
||||||
f := strings.Fields(out)
|
f := strings.Fields(out)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package git
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"slices"
|
"slices"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"submarine/internal/submodule"
|
"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 {
|
tests := []struct {
|
||||||
out string
|
es []entry
|
||||||
want submodule.Worktree
|
want submodule.Worktree
|
||||||
}{
|
}{
|
||||||
{"", submodule.WorktreeClean},
|
{nil, submodule.WorktreeClean},
|
||||||
{"? new.txt", submodule.WorktreeUntracked},
|
{[]entry{untracked}, submodule.WorktreeUntracked},
|
||||||
{"? new.txt\n1 .M N... 100644 100644 100644 a b README", submodule.WorktreeModified},
|
{[]entry{untracked, modified}, submodule.WorktreeModified},
|
||||||
{"2 R. N... 100644 100644 100644 a b R100 new\told", submodule.WorktreeModified},
|
{[]entry{modified, untracked, conflict}, submodule.WorktreeConflict},
|
||||||
{"1 .M N... 100644 100644 100644 a b README\nu UU N... 1 2 3 4 a b c README", submodule.WorktreeConflict},
|
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
if got := parseWorktree(tt.out); got != tt.want {
|
if got := worktreeOf(tt.es); got != tt.want {
|
||||||
t.Errorf("parseWorktree(%q) = %v, want %v", tt.out, 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"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
@@ -182,11 +183,15 @@ func status(ctx context.Context, in info) (*submodule.Status, error) {
|
|||||||
case !onBranch:
|
case !onBranch:
|
||||||
st.Checkout = submodule.CheckoutDetached
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
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
|
// the pin must exist locally to say anything about it
|
||||||
if _, ok, err := try(ctx, in.dir, "cat-file", "-e", pin+"^{commit}"); err != nil {
|
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
|
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) {
|
func commit(ctx context.Context, dir, rev string) (*submodule.Commit, error) {
|
||||||
out, err := run(ctx, dir, "log", "-1", commitFormat, rev, "--")
|
out, err := run(ctx, dir, "log", "-1", commitFormat, rev, "--")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -3,12 +3,14 @@ package git
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
"slices"
|
"slices"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"submarine/internal/submodule"
|
"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) })
|
submodule.Walk(repo.Submodules, func(s *submodule.Submodule) { paths = append(paths, s.Path) })
|
||||||
want := []string{
|
want := []string{
|
||||||
"src/core", "src/ui", "src/net", "libs/store", "libs/log", "libs/log/modules/nested",
|
"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) {
|
if !slices.Equal(paths, want) {
|
||||||
t.Errorf("paths = %v\nwant %v", 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/staged", ref: "origin/main", source: submodule.RefFromCurrentBranch, staged: true},
|
||||||
{path: "libs/missing", issues: []submodule.IssueKind{submodule.IssueMissing}},
|
{path: "libs/missing", issues: []submodule.IssueKind{submodule.IssueMissing}},
|
||||||
{path: "vendor", issues: []submodule.IssueKind{submodule.IssueChanged}, ref: "origin/main", source: submodule.RefFromCurrentBranch, behind: 2},
|
{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 {
|
for _, tt := range tests {
|
||||||
st, err := p.Status(context.Background(), tt.path)
|
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) {
|
func TestStatusMissingPresence(t *testing.T) {
|
||||||
p, _ := loadFixture(t)
|
p, _ := loadFixture(t)
|
||||||
st, err := p.Status(context.Background(), "libs/missing")
|
st, err := p.Status(context.Background(), "libs/missing")
|
||||||
|
|||||||
@@ -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 {
|
func fixture() []node {
|
||||||
c1 := commit("3f9a1c2", "Bump golangci-lint to v2", "anna", 26*time.Hour)
|
c1 := commit("3f9a1c2", "Bump golangci-lint to v2", "anna", 26*time.Hour)
|
||||||
c2 := commit("a1b2c3d", "Initial import", "ben", 40*24*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 := inSync("origin/main", commit("77aa88b", "Merge PR #42", "dana", 5*time.Hour))
|
||||||
srcSub2.Worktree = submodule.WorktreeModified
|
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)
|
subSubPin := commit("4e5f6a7", "Add feature flag", "eve", 30*time.Hour)
|
||||||
subSubTip := commit("9f8e7d6", "Fix login timeout", "anna", 2*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 := inSync("origin/main", commit("1a2b3c4", "Add v3 schema", "ben", 6*24*time.Hour))
|
||||||
schema.Worktree = submodule.WorktreeUntracked
|
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)
|
unpushed := commit("b7c8d9e", "Hotfix null pointer in parser", "you", 20*time.Minute)
|
||||||
sub3Tip := commit("6f5e4d3", "Release 2.1.0", "chris", 4*24*time.Hour)
|
sub3Tip := commit("6f5e4d3", "Release 2.1.0", "chris", 4*24*time.Hour)
|
||||||
|
|||||||
@@ -2,7 +2,12 @@
|
|||||||
// (recursively nested) git submodules. It has no UI dependencies.
|
// (recursively nested) git submodules. It has no UI dependencies.
|
||||||
package submodule
|
package submodule
|
||||||
|
|
||||||
import "time"
|
import (
|
||||||
|
"cmp"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
// Repo is the superproject.
|
// Repo is the superproject.
|
||||||
type Repo struct {
|
type Repo struct {
|
||||||
@@ -100,6 +105,68 @@ type Commit struct {
|
|||||||
When time.Time
|
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.
|
// Status is the full state of one submodule.
|
||||||
type Status struct {
|
type Status struct {
|
||||||
Presence Presence
|
Presence Presence
|
||||||
@@ -111,4 +178,5 @@ type Status struct {
|
|||||||
CheckedOut *Commit
|
CheckedOut *Commit
|
||||||
Tip *Commit
|
Tip *Commit
|
||||||
Between []Commit // commits that make up the pin <-> tip difference
|
Between []Commit // commits that make up the pin <-> tip difference
|
||||||
|
Changes *Changes // nil if the worktree is clean or the details are unknown
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -82,6 +82,10 @@ func (m Model) lines(s *submodule.Submodule) []string {
|
|||||||
if st.Checkout == submodule.CheckoutDetached {
|
if st.Checkout == submodule.CheckoutDetached {
|
||||||
out = append(out, label("")+muted.Render("detached HEAD on pin"))
|
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, "")
|
out = append(out, "")
|
||||||
|
|
||||||
// ref
|
// ref
|
||||||
|
|||||||
@@ -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.
|
// LevelStyle returns base with the text color (and weight) for a level.
|
||||||
func (t Theme) LevelStyle(l submodule.Level, base lipgloss.Style) lipgloss.Style {
|
func (t Theme) LevelStyle(l submodule.Level, base lipgloss.Style) lipgloss.Style {
|
||||||
switch l {
|
switch l {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
type Theme struct {
|
type Theme struct {
|
||||||
Primary lipgloss.TerminalColor // accent: branch names, commit hashes
|
Primary lipgloss.TerminalColor // accent: branch names, commit hashes
|
||||||
Info lipgloss.TerminalColor
|
Info lipgloss.TerminalColor
|
||||||
|
Added lipgloss.TerminalColor // added lines and files
|
||||||
Warn lipgloss.TerminalColor
|
Warn lipgloss.TerminalColor
|
||||||
Danger lipgloss.TerminalColor
|
Danger lipgloss.TerminalColor
|
||||||
Muted lipgloss.TerminalColor
|
Muted lipgloss.TerminalColor
|
||||||
@@ -31,6 +32,7 @@ func Default() Theme {
|
|||||||
return Theme{
|
return Theme{
|
||||||
Primary: lipgloss.Color("6"), // cyan
|
Primary: lipgloss.Color("6"), // cyan
|
||||||
Info: lipgloss.Color("4"), // blue
|
Info: lipgloss.Color("4"), // blue
|
||||||
|
Added: lipgloss.Color("2"), // green
|
||||||
Warn: lipgloss.Color("3"), // yellow
|
Warn: lipgloss.Color("3"), // yellow
|
||||||
Danger: lipgloss.Color("1"), // red
|
Danger: lipgloss.Color("1"), // red
|
||||||
Muted: lipgloss.Color("8"), // bright black / grey
|
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.
|
// Glyphs are the few symbols used next to the state words.
|
||||||
type Glyphs struct {
|
type Glyphs struct {
|
||||||
Staged, Ahead, Behind, Collapsed, Expanded, Problems string
|
Staged, Ahead, Behind, Collapsed, Expanded, Problems, Minus, From, Rule string
|
||||||
}
|
}
|
||||||
|
|
||||||
func Unicode() Glyphs {
|
func Unicode() Glyphs {
|
||||||
return Glyphs{
|
return Glyphs{
|
||||||
Staged: "●", Ahead: "↑", Behind: "↓", Collapsed: "▸", Expanded: "▾", Problems: "·",
|
Staged: "●", Ahead: "↑", Behind: "↓", Collapsed: "▸", Expanded: "▾", Problems: "·", Minus: "−", From: "←", Rule: "─",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func ASCII() Glyphs {
|
func ASCII() Glyphs {
|
||||||
return Glyphs{
|
return Glyphs{
|
||||||
Staged: "*", Ahead: "^", Behind: "v", Collapsed: ">", Expanded: "v", Problems: ".",
|
Staged: "*", Ahead: "^", Behind: "v", Collapsed: ">", Expanded: "v", Problems: ".", Minus: "-", From: "<-", Rule: "-",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+23
-1
@@ -42,7 +42,7 @@ upstream() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
mkdir -p "$root/remotes" "$root/work"
|
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
|
# "feature" has a branch that main doesn't contain
|
||||||
g "$root/work/feature" switch -c feat/search
|
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" push origin develop
|
||||||
g "$root/work/store" switch main
|
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"
|
# "nested" becomes a submodule of "log"
|
||||||
g "$root/work/log" submodule add "$root/remotes/nested.git" modules/nested
|
g "$root/work/log" submodule add "$root/remotes/nested.git" modules/nested
|
||||||
g "$root/work/log" commit -m "Add nested"
|
g "$root/work/log" commit -m "Add nested"
|
||||||
@@ -76,6 +87,7 @@ add core libs/conflict
|
|||||||
add ui libs/staged
|
add ui libs/staged
|
||||||
add net libs/missing
|
add net libs/missing
|
||||||
add ui vendor
|
add ui vendor
|
||||||
|
add work libs/work
|
||||||
g "$super" submodule update --init --recursive
|
g "$super" submodule update --init --recursive
|
||||||
g "$super" commit -m "Add submodules"
|
g "$super" commit -m "Add submodules"
|
||||||
|
|
||||||
@@ -106,6 +118,16 @@ echo dirty >>"$super/libs/log/modules/nested/README"
|
|||||||
# vendor: untracked file
|
# vendor: untracked file
|
||||||
echo scratch >"$super/vendor/scratch.txt"
|
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
|
# libs/staged: new pin staged in the superproject, not committed
|
||||||
g "$super/libs/staged" merge --ff-only origin/main
|
g "$super/libs/staged" merge --ff-only origin/main
|
||||||
g "$super" add libs/staged
|
g "$super" add libs/staged
|
||||||
|
|||||||
Reference in New Issue
Block a user