Archived
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99f1ba2bec | ||
|
|
651b0c2e69 | ||
|
|
2aeebfe3b1 |
@@ -103,8 +103,10 @@ clipboard, ready for `cd <paste>`. It works over SSH too (OSC 52; in tmux,
|
|||||||
Select a submodule (or the header for the whole superproject) and press `x`.
|
Select a submodule (or the header for the whole superproject) and press `x`.
|
||||||
Its worktree becomes exactly what git records, and so do all submodules
|
Its worktree becomes exactly what git records, and so do all submodules
|
||||||
below it: changes are reset, untracked files removed, merges and rebases
|
below it: changes are reset, untracked files removed, merges and rebases
|
||||||
ended, missing submodules cloned. Staged pins are kept. If something would be
|
ended, missing submodules cloned. Staged pins are kept. A submodule whose pin
|
||||||
lost, you are asked first:
|
is a branch tip ends on that branch instead of a detached HEAD (a missing
|
||||||
|
local branch is created from the remote one; no branch is ever moved). If
|
||||||
|
something would be lost, you are asked first:
|
||||||
|
|
||||||
```
|
```
|
||||||
Restore src/app? Discards 7 changed files in 3 repositories. y restore · n cancel
|
Restore src/app? Discards 7 changed files in 3 repositories. y restore · n cancel
|
||||||
|
|||||||
+37
-4
@@ -86,7 +86,8 @@ After a successful restore of repository R with target T:
|
|||||||
any depth.
|
any depth.
|
||||||
|
|
||||||
So the subtree shows `in sync` everywhere, except for states restore does
|
So the subtree shows `in sync` everywhere, except for states restore does
|
||||||
not change: `unpushed`, behind/ahead of the ref, and `detached HEAD on pin`.
|
not change: `unpushed`, behind/ahead of the ref, and `detached HEAD on pin`
|
||||||
|
where no branch is at the pin (see [Branches](#branches)).
|
||||||
|
|
||||||
The recursion follows **T**, not the tree loaded in the UI. A submodule that
|
The recursion follows **T**, not the tree loaded in the UI. A submodule that
|
||||||
is only in T (for example, after switching branches) is initialized. A
|
is only in T (for example, after switching branches) is initialized. A
|
||||||
@@ -100,8 +101,25 @@ submodule that is only on disk is deleted as an untracked directory.
|
|||||||
the branch stays checked out.
|
the branch stays checked out.
|
||||||
- **Submodule off the pin:** HEAD is detached at the target. The branch that
|
- **Submodule off the pin:** HEAD is detached at the target. The branch that
|
||||||
was checked out is **not moved**, so its commits stay on it.
|
was checked out is **not moved**, so its commits stay on it.
|
||||||
|
- **Submodule detached at the target** (after the step above, or because it
|
||||||
|
was already so, also when it was in sync): HEAD is put on a branch whose
|
||||||
|
tip is the target, the first that fits of ([D28](decisions.md)):
|
||||||
|
1. a local branch tracking the ref the UI shows (or named like it), e.g.
|
||||||
|
local `main` tracking `origin/main`, also when the pin is behind or
|
||||||
|
ahead of the ref;
|
||||||
|
2. the ref itself, if its tip is the target and no local branch has its
|
||||||
|
name: the local branch is created, tracking it;
|
||||||
|
3. any other local branch, the first alphabetically;
|
||||||
|
4. any other remote branch whose name no local branch has, created as a
|
||||||
|
tracking branch; the ref's remote first, then alphabetically.
|
||||||
|
|
||||||
Restore never moves, creates or deletes a branch.
|
If git refuses one (checked out in another worktree, a name conflict),
|
||||||
|
the next is tried. If none fits, HEAD stays detached.
|
||||||
|
|
||||||
|
Restore never moves or deletes a branch. A local branch that is behind or
|
||||||
|
ahead of its remote branch is no candidate and is not fast-forwarded. The
|
||||||
|
only branches restore creates are tracking branches for remote branches at
|
||||||
|
the target, in steps 2 and 4.
|
||||||
|
|
||||||
### What can be lost
|
### What can be lost
|
||||||
|
|
||||||
@@ -187,6 +205,14 @@ submodule update --init --checkout --force -- <not yet initialized children>
|
|||||||
`--checkout` overrides `submodule.<name>.update`, and the explicit paths
|
`--checkout` overrides `submodule.<name>.update`, and the explicit paths
|
||||||
override `submodule.<name>.active`, so no child module is skipped.
|
override `submodule.<name>.active`, so no child module is skipped.
|
||||||
|
|
||||||
|
For a submodule, after `submodule sync`, `attachBranch` puts a detached HEAD
|
||||||
|
on a branch: `symbolic-ref -q HEAD` (on a branch: done), `for-each-ref
|
||||||
|
--points-at=T refs/heads refs/remotes` (no branch at T: done), then the ref
|
||||||
|
is resolved like `Status` does, and the first candidate (see
|
||||||
|
[Branches](#branches)) that `switch` / `switch -c <local> --track <remote>`
|
||||||
|
accepts wins. The branch checked out before the restore is passed to the
|
||||||
|
ref resolution. A child's `.gitmodules` branch is read once per parent.
|
||||||
|
|
||||||
With `c` the parent's index gets the committed pin first (`update-index
|
With `c` the parent's index gets the committed pin first (`update-index
|
||||||
--cacheinfo` in the parent).
|
--cacheinfo` in the parent).
|
||||||
|
|
||||||
@@ -261,7 +287,9 @@ restore or switch runs at a time.
|
|||||||
|
|
||||||
`git.Provider.Switch` (`internal/submodule/git/switch.go`): verify the ref is
|
`git.Provider.Switch` (`internal/submodule/git/switch.go`): verify the ref is
|
||||||
still `T`; `update-index --cacheinfo` in the parent; `restoreRepo` (see
|
still `T`; `update-index --cacheinfo` in the parent; `restoreRepo` (see
|
||||||
restore); then `switch` / `switch -c <local> --track <remote-branch>`.
|
restore); then `switch` / `switch -c <local> --track <remote-branch>`. The restore leaves HEAD of the
|
||||||
|
switched repository alone (Switch puts it on the chosen branch); the
|
||||||
|
submodules below it are attached as in a restore.
|
||||||
`SwitchLoss` is `lossOf` with `T` as the target. `Branches` is one
|
`SwitchLoss` is `lossOf` with `T` as the target. `Branches` is one
|
||||||
`for-each-ref`, without the `origin/HEAD` symref.
|
`for-each-ref`, without the `origin/HEAD` symref.
|
||||||
|
|
||||||
@@ -337,7 +365,12 @@ action, hence `x`.
|
|||||||
not moved, ignored files survive, a merge and a rebase in progress end, a
|
not moved, ignored files survive, a merge and a rebase in progress end, a
|
||||||
standalone clone and a removed submodule's directory are deleted, a missing
|
standalone clone and a removed submodule's directory are deleted, a missing
|
||||||
submodule is cloned again, `update = none` and inactive submodules are
|
submodule is cloned again, `update = none` and inactive submodules are
|
||||||
restored, an unavailable commit is reported. The loss is empty for off pin,
|
restored, an unavailable commit is reported. A detached submodule ends on
|
||||||
|
the local branch tracking the ref, else on the ref created as a tracking
|
||||||
|
branch (before other branches at the pin), else on the first local
|
||||||
|
branch; a local branch elsewhere is not moved; a checked-out branch stays;
|
||||||
|
a branch of another worktree is skipped; nested submodules are attached
|
||||||
|
too. The loss is empty for off pin,
|
||||||
missing and unpushed submodules, counts files, nested changes, merges,
|
missing and unpushed submodules, counts files, nested changes, merges,
|
||||||
detached commits (not those on a branch) and standalone repositories.
|
detached commits (not those on a branch) and standalone repositories.
|
||||||
- UI (`internal/tui/restore_test.go`): restores at once without a loss, asks
|
- UI (`internal/tui/restore_test.go`): restores at once without a loss, asks
|
||||||
|
|||||||
+32
-1
@@ -327,7 +327,8 @@ the same for every submodule of the target tree, initializing missing ones.
|
|||||||
- *Ignored files stay* (`clean` without `-x`): `.env`, `node_modules` and
|
- *Ignored files stay* (`clean` without `-x`): `.env`, `node_modules` and
|
||||||
IDE settings are not "additional files" worth deleting in every submodule.
|
IDE settings are not "additional files" worth deleting in every submodule.
|
||||||
- *Branches never move.* A checkout that is not on the target is detached
|
- *Branches never move.* A checkout that is not on the target is detached
|
||||||
first, so commits stay on their branch.
|
first, so commits stay on their branch. (A submodule is then put on a
|
||||||
|
branch at the target if there is one, see D28.)
|
||||||
- *It asks only if something is lost* (changed or untracked files,
|
- *It asks only if something is lost* (changed or untracked files,
|
||||||
operations in progress, commits on no ref, standalone repositories).
|
operations in progress, commits on no ref, standalone repositories).
|
||||||
Never asking would delete repositories that exist nowhere else; always
|
Never asking would delete repositories that exist nowhere else; always
|
||||||
@@ -499,3 +500,33 @@ timeout, failures are silent), which supersedes the once-a-day rule of D21.
|
|||||||
N1 still holds: the TUI never waits for it. `q` changes meaning while an
|
N1 still holds: the TUI never waits for it. `q` changes meaning while an
|
||||||
update is available, so muscle memory that exits with `q` now updates; the
|
update is available, so muscle memory that exits with `q` now updates; the
|
||||||
bar says so. Typing `q` in the filter or a popup is unchanged.
|
bar says so. Typing `q` in the filter or a popup is unchanged.
|
||||||
|
|
||||||
|
## D28 — Restore puts a submodule on a branch at its pin
|
||||||
|
|
||||||
|
**Context.** After a restore (for example after the superproject's branch
|
||||||
|
changed) every submodule was a detached HEAD, so IDEs showed only hashes.
|
||||||
|
**Decision.** Amends D23. After restoring a submodule, at any depth, a
|
||||||
|
detached HEAD is put on a branch whose tip is the pin, in this order: (1) a
|
||||||
|
local branch tracking the shown ref (or named like it), (2) the ref itself
|
||||||
|
as a new local tracking branch, if no local branch has its name, (3) any
|
||||||
|
other local branch, alphabetically, (4) any other remote branch whose name
|
||||||
|
no local branch has, as a new tracking branch, the ref's remote first. A
|
||||||
|
HEAD already on a branch stays there. If git refuses a branch (checked out
|
||||||
|
in another worktree, a name conflict), the next one is tried; if none fits,
|
||||||
|
HEAD stays detached and the restore still succeeds. Submodules that were in
|
||||||
|
sync are included. The superproject is not affected.
|
||||||
|
**Rationale.** Attaching a branch whose tip is the pin changes no commit and
|
||||||
|
no file, so nothing can be lost and the loss question stays the same. The
|
||||||
|
ref comes first because it is what the UI shows; a local branch beats a new
|
||||||
|
one so restore adds as few branches as possible.
|
||||||
|
**Alternatives.** Fast-forwarding a local branch that is behind its remote
|
||||||
|
branch at the pin: rejected, restore still never moves a branch. Staying
|
||||||
|
detached when several branches fit: rejected, the order above always gives
|
||||||
|
one answer, the same on every run.
|
||||||
|
**Consequences.** Restore now may create local branches (only for remote
|
||||||
|
branches at the pin whose name has none). Per detached submodule it costs a
|
||||||
|
`symbolic-ref` and one `for-each-ref --points-at`; only if a branch is at
|
||||||
|
the pin, the ref is resolved (as for `Status`) and one `switch` runs. No
|
||||||
|
network. `detached HEAD on pin` is now left only where no branch is at the
|
||||||
|
pin. Switch keeps its own rule for the repository it switches; the
|
||||||
|
submodules below it follow this one.
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 420 KiB After Width: | Height: | Size: 143 KiB |
@@ -145,7 +145,8 @@ commit hashes. It doesn't answer the questions users actually have:
|
|||||||
the header is selected): its worktree becomes exactly what git records, and
|
the header is selected): its worktree becomes exactly what git records, and
|
||||||
so do all submodules below it, recursively. Staged pins are kept at every
|
so do all submodules below it, recursively. Staged pins are kept at every
|
||||||
depth. For a submodule with a staged pin it asks whether to restore to the
|
depth. For a submodule with a staged pin it asks whether to restore to the
|
||||||
staged or the committed pin (the latter unstages it). It asks for
|
staged or the committed pin (the latter unstages it). A restored submodule
|
||||||
|
whose pin is a branch tip ends on that branch, not detached. It asks for
|
||||||
confirmation only if something would be lost. See [actions.md](actions.md#restore).
|
confirmation only if something would be lost. See [actions.md](actions.md#restore).
|
||||||
|
|
||||||
- **F57** `b` opens a popup in the middle of the screen with a search line
|
- **F57** `b` opens a popup in the middle of the screen with a search line
|
||||||
|
|||||||
@@ -327,17 +327,10 @@ func pinState(ctx context.Context, in info, st *submodule.Status, pin, head, bra
|
|||||||
return fmt.Errorf("pinned commit %.7s not fetched", pin)
|
return fmt.Errorf("pinned commit %.7s not fetched", pin)
|
||||||
}
|
}
|
||||||
|
|
||||||
// remote branches containing the pin, oldest tip first
|
containing, err := remoteContaining(ctx, in.dir, pin)
|
||||||
out, err := run(ctx, in.dir, "for-each-ref", "--contains", pin, "--sort=committerdate", "--format=%(refname)", "refs/remotes")
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
var containing []string
|
|
||||||
for _, r := range strings.Fields(out) {
|
|
||||||
if !strings.HasSuffix(r, "/HEAD") {
|
|
||||||
containing = append(containing, strings.TrimPrefix(r, "refs/remotes/"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
st.Pin.Pushed = len(containing) > 0
|
st.Pin.Pushed = len(containing) > 0
|
||||||
|
|
||||||
var ref string
|
var ref string
|
||||||
@@ -359,6 +352,7 @@ func pinState(ctx context.Context, in info, st *submodule.Status, pin, head, bra
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
st.CheckedOut = st.Pinned
|
st.CheckedOut = st.Pinned
|
||||||
|
var out string
|
||||||
if head != pin {
|
if head != pin {
|
||||||
if st.CheckedOut, err = commit(ctx, in.dir, head); err != nil {
|
if st.CheckedOut, err = commit(ctx, in.dir, head); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -417,6 +411,22 @@ func pinState(ctx context.Context, in info, st *submodule.Status, pin, head, bra
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// remoteContaining lists the remote branches containing pin, oldest tip
|
||||||
|
// first, without the origin/HEAD symref.
|
||||||
|
func remoteContaining(ctx context.Context, dir, pin string) ([]string, error) {
|
||||||
|
out, err := run(ctx, dir, "for-each-ref", "--contains", pin, "--sort=committerdate", "--format=%(refname)", "refs/remotes")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var containing []string
|
||||||
|
for _, r := range strings.Fields(out) {
|
||||||
|
if !strings.HasSuffix(r, "/HEAD") {
|
||||||
|
containing = append(containing, strings.TrimPrefix(r, "refs/remotes/"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return containing, nil
|
||||||
|
}
|
||||||
|
|
||||||
// resolve finds the ref the pin is compared with, following the order in
|
// resolve finds the ref the pin is compared with, following the order in
|
||||||
// docs/state-model.md: the target (see targetRef) if the pin is on it, else the
|
// docs/state-model.md: the target (see targetRef) if the pin is on it, else the
|
||||||
// branch containing the pin that is closest to it, else the target anyway,
|
// branch containing the pin that is closest to it, else the target anyway,
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"os"
|
"os"
|
||||||
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -45,7 +47,7 @@ func (p *Provider) RestoreLoss(ctx context.Context, path string, to submodule.Re
|
|||||||
// submodule is cloned and missing commits are fetched; git never prompts.
|
// submodule is cloned and missing commits are fetched; git never prompts.
|
||||||
func (p *Provider) Restore(ctx context.Context, path string, to submodule.RestoreTarget) error {
|
func (p *Provider) Restore(ctx context.Context, path string, to submodule.RestoreTarget) error {
|
||||||
if path == "" {
|
if path == "" {
|
||||||
return p.restoreRepo(ctx, p.root, "HEAD")
|
return p.restoreRepo(ctx, p.root, "HEAD", nil)
|
||||||
}
|
}
|
||||||
p.mu.RLock()
|
p.mu.RLock()
|
||||||
in, ok := p.mods[path]
|
in, ok := p.mods[path]
|
||||||
@@ -67,7 +69,7 @@ func (p *Provider) Restore(ctx context.Context, path string, to submodule.Restor
|
|||||||
return fmt.Errorf("%s: %w", path, err)
|
return fmt.Errorf("%s: %w", path, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return p.restoreRepo(ctx, in.dir, pin)
|
return p.restoreRepo(ctx, in.dir, pin, &in)
|
||||||
}
|
}
|
||||||
|
|
||||||
// restoreTarget returns the directory and the commit path is restored to:
|
// restoreTarget returns the directory and the commit path is restored to:
|
||||||
@@ -123,7 +125,9 @@ func pinOf(ctx context.Context, in info, to submodule.RestoreTarget) (string, er
|
|||||||
// or "HEAD") and then every submodule its index records. Branches are never
|
// or "HEAD") and then every submodule its index records. Branches are never
|
||||||
// moved: a checkout that is not on target is detached first. If the checkout
|
// moved: a checkout that is not on target is detached first. If the checkout
|
||||||
// stays on target, its staged pins are kept and are the submodules' targets.
|
// stays on target, its staged pins are kept and are the submodules' targets.
|
||||||
func (p *Provider) restoreRepo(ctx context.Context, dir, target string) error {
|
// For a submodule (in is not nil) a detached HEAD is then put on a branch at
|
||||||
|
// target if there is one, see attachBranch; the submodules below always are.
|
||||||
|
func (p *Provider) restoreRepo(ctx context.Context, dir, target string, in *info) error {
|
||||||
env := p.networkEnv()
|
env := p.networkEnv()
|
||||||
g := func(args ...string) (string, error) {
|
g := func(args ...string) (string, error) {
|
||||||
return p.exec(ctx, dir, env, append(append([]string{}, restoreArgs...), args...)...)
|
return p.exec(ctx, dir, env, append(append([]string{}, restoreArgs...), args...)...)
|
||||||
@@ -138,6 +142,7 @@ func (p *Provider) restoreRepo(ctx context.Context, dir, target string) error {
|
|||||||
if gitDir, _, err := gitDirs(dir); err == nil {
|
if gitDir, _, err := gitDirs(dir); err == nil {
|
||||||
p.clearOperations(ctx, dir, gitDir)
|
p.clearOperations(ctx, dir, gitDir)
|
||||||
}
|
}
|
||||||
|
prev, _, _ := try(ctx, dir, "symbolic-ref", "--short", "-q", "HEAD") // for the ref, see attachBranch
|
||||||
var keep []gitlink
|
var keep []gitlink
|
||||||
if head, err := g("rev-parse", "--verify", "-q", "HEAD"); err != nil || head != sha {
|
if head, err := g("rev-parse", "--verify", "-q", "HEAD"); err != nil || head != sha {
|
||||||
if _, err := g("checkout", "--force", "--detach", sha); err != nil {
|
if _, err := g("checkout", "--force", "--detach", sha); err != nil {
|
||||||
@@ -161,6 +166,11 @@ func (p *Provider) restoreRepo(ctx context.Context, dir, target string) error {
|
|||||||
if _, err := g("submodule", "sync", "-q"); err != nil {
|
if _, err := g("submodule", "sync", "-q"); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if in != nil {
|
||||||
|
if err := p.attachBranch(ctx, *in, sha, prev); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
links, err := indexGitlinks(ctx, dir)
|
links, err := indexGitlinks(ctx, dir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -178,6 +188,16 @@ func (p *Provider) restoreRepo(ctx context.Context, dir, target string) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// the .gitmodules branch of each child, for its ref
|
||||||
|
branches := map[string]string{}
|
||||||
|
if out, ok, err := try(ctx, dir, "config", "-f", ".gitmodules", "--get-regexp", `^submodule\..*\.(path|branch)$`); err != nil {
|
||||||
|
return err
|
||||||
|
} else if ok {
|
||||||
|
for _, m := range parseGitmodules(out) {
|
||||||
|
branches[path.Clean(m.path)] = m.branch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// the children are independent repositories
|
// the children are independent repositories
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
errs := make([]error, len(links))
|
errs := make([]error, len(links))
|
||||||
@@ -185,7 +205,8 @@ func (p *Provider) restoreRepo(ctx context.Context, dir, target string) error {
|
|||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
if err := p.restoreRepo(ctx, filepath.Join(dir, filepath.FromSlash(l.path)), l.sha); err != nil {
|
child := info{superDir: dir, dir: filepath.Join(dir, filepath.FromSlash(l.path)), relPath: l.path, branch: branches[l.path]}
|
||||||
|
if err := p.restoreRepo(ctx, child.dir, l.sha, &child); err != nil {
|
||||||
errs[i] = err
|
errs[i] = err
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
@@ -194,6 +215,135 @@ func (p *Provider) restoreRepo(ctx context.Context, dir, target string) error {
|
|||||||
return errors.Join(errs...)
|
return errors.Join(errs...)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// attachBranch puts the detached HEAD of the restored submodule in on a
|
||||||
|
// branch whose tip is sha, the first that git accepts of, in this order:
|
||||||
|
//
|
||||||
|
// 1. a local branch tracking the ref the UI shows (see resolve), or named
|
||||||
|
// like it;
|
||||||
|
// 2. the ref itself, as a new local branch tracking it, if no local branch
|
||||||
|
// has its name;
|
||||||
|
// 3. any other local branch, alphabetically;
|
||||||
|
// 4. any other remote branch whose name no local branch has, as a new
|
||||||
|
// tracking branch; the ref's remote first, then alphabetically.
|
||||||
|
//
|
||||||
|
// A HEAD that is on a branch stays there. No branch is moved: a local branch
|
||||||
|
// elsewhere is no candidate, and its remote branch neither. If nothing fits,
|
||||||
|
// HEAD stays detached; only a context error is returned. prev is the branch
|
||||||
|
// checked out before the restore; the ref depends on it.
|
||||||
|
func (p *Provider) attachBranch(ctx context.Context, in info, sha, prev string) error {
|
||||||
|
if _, onBranch, err := try(ctx, in.dir, "symbolic-ref", "-q", "HEAD"); err != nil || onBranch {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out, ok, err := try(ctx, in.dir, "for-each-ref", "--points-at="+sha,
|
||||||
|
"--format=%(refname)%00%(upstream)%00%(symref)", "refs/heads", "refs/remotes")
|
||||||
|
if err != nil || !ok {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var locals, remotes []string // alphabetical, as for-each-ref sorts them
|
||||||
|
upstreams := map[string]string{}
|
||||||
|
for _, line := range strings.Split(out, "\n") {
|
||||||
|
f := strings.Split(line, "\x00")
|
||||||
|
if len(f) != 3 || f[2] != "" { // a symref is origin/HEAD
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if name, ok := strings.CutPrefix(f[0], "refs/heads/"); ok {
|
||||||
|
locals = append(locals, name)
|
||||||
|
upstreams[name] = strings.TrimPrefix(f[1], "refs/remotes/")
|
||||||
|
} else if name, ok := strings.CutPrefix(f[0], "refs/remotes/"); ok {
|
||||||
|
remotes = append(remotes, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(locals)+len(remotes) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// the ref, if it is a remote branch
|
||||||
|
var ref string
|
||||||
|
containing, err := remoteContaining(ctx, in.dir, sha)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if r, source, _, err := resolve(ctx, in, sha, prev, containing); err != nil {
|
||||||
|
return err
|
||||||
|
} else if source != submodule.RefFromTag && source != submodule.RefFromSHA {
|
||||||
|
ref = r
|
||||||
|
}
|
||||||
|
|
||||||
|
localOf := func(remote string) string { _, l, _ := strings.Cut(remote, "/"); return l }
|
||||||
|
hasLocal := func(name string) (bool, error) {
|
||||||
|
_, ok, err := try(ctx, in.dir, "rev-parse", "--verify", "-q", "refs/heads/"+name)
|
||||||
|
return ok, err
|
||||||
|
}
|
||||||
|
type candidate struct{ local, remote string } // remote: create local, tracking it
|
||||||
|
var cands []candidate
|
||||||
|
addRemote := func(r string) error {
|
||||||
|
l := localOf(r)
|
||||||
|
if l == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
exists, err := hasLocal(l)
|
||||||
|
if err == nil && !exists {
|
||||||
|
cands = append(cands, candidate{l, r})
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if ref != "" {
|
||||||
|
for _, l := range locals {
|
||||||
|
if upstreams[l] == ref {
|
||||||
|
cands = append(cands, candidate{local: l})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if l := localOf(ref); slices.Contains(locals, l) {
|
||||||
|
cands = append(cands, candidate{local: l})
|
||||||
|
}
|
||||||
|
if slices.Contains(remotes, ref) {
|
||||||
|
if err := addRemote(ref); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, l := range locals {
|
||||||
|
cands = append(cands, candidate{local: l})
|
||||||
|
}
|
||||||
|
preferred, _, _ := strings.Cut(ref, "/")
|
||||||
|
if preferred == "" {
|
||||||
|
if preferred, err = remoteName(ctx, in.dir); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
slices.SortStableFunc(remotes, func(a, b string) int {
|
||||||
|
ap, bp := strings.HasPrefix(a, preferred+"/"), strings.HasPrefix(b, preferred+"/")
|
||||||
|
switch {
|
||||||
|
case ap && !bp:
|
||||||
|
return -1
|
||||||
|
case bp && !ap:
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
})
|
||||||
|
for _, r := range remotes {
|
||||||
|
if r != ref {
|
||||||
|
if err := addRemote(r); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range cands {
|
||||||
|
args := append(append([]string{}, restoreArgs...), "switch", "-q", c.local)
|
||||||
|
if c.remote != "" {
|
||||||
|
args = append(append([]string{}, restoreArgs...), "switch", "-q", "-c", c.local, "--track", c.remote)
|
||||||
|
}
|
||||||
|
if _, err := p.exec(ctx, in.dir, nil, args...); err == nil {
|
||||||
|
return nil
|
||||||
|
} else if ctx.Err() != nil {
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
// e.g. checked out in another worktree: try the next one
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// networkEnv is the environment of commands that may clone or fetch.
|
// networkEnv is the environment of commands that may clone or fetch.
|
||||||
func (p *Provider) networkEnv() []string {
|
func (p *Provider) networkEnv() []string {
|
||||||
return append(append([]string{}, p.sshEnv...), p.extraEnv...)
|
return append(append([]string{}, p.sshEnv...), p.extraEnv...)
|
||||||
|
|||||||
@@ -332,3 +332,130 @@ func TestRestoreLossCountsStagedPinsOfAMovingRepository(t *testing.T) {
|
|||||||
t.Errorf("loss %+v, want 1 staged pin and the 2 detached nested commits", l)
|
t.Errorf("loss %+v, want 1 staged pin and the 2 detached nested commits", l)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// restoreSub restores the submodule path and returns the branch its HEAD is
|
||||||
|
// on ("" if detached).
|
||||||
|
func restoreSub(t *testing.T, p *Provider, super, path string) string {
|
||||||
|
t.Helper()
|
||||||
|
if err := p.Restore(context.Background(), path, submodule.RestoreStaged); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return out(t, filepath.Join(super, filepath.FromSlash(path)), "branch", "--show-current")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRestoreAttachesTheLocalBranchTrackingTheRef(t *testing.T) {
|
||||||
|
p, root := ownFixture(t)
|
||||||
|
super := filepath.Join(root, "super")
|
||||||
|
// src/ui: in sync, the pin is behind origin/main, local main is at the pin
|
||||||
|
git(t, filepath.Join(super, "src", "ui"), "checkout", "-q", "--detach")
|
||||||
|
git(t, filepath.Join(super, "src", "ui"), "branch", "aaa") // alphabetically first, but no ref
|
||||||
|
|
||||||
|
if got := restoreSub(t, p, super, "src/ui"); got != "main" {
|
||||||
|
t.Errorf("branch = %q, want main", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRestoreCreatesTheRefBranchBeforeOtherBranches(t *testing.T) {
|
||||||
|
p, root := ownFixture(t)
|
||||||
|
super := filepath.Join(root, "super")
|
||||||
|
log := filepath.Join(super, "libs", "log")
|
||||||
|
// libs/log: the pin is the tip of origin/main and origin/archive
|
||||||
|
git(t, log, "checkout", "-q", "--detach")
|
||||||
|
git(t, log, "branch", "-D", "main")
|
||||||
|
|
||||||
|
if got := restoreSub(t, p, super, "libs/log"); got != "main" {
|
||||||
|
t.Errorf("branch = %q, want main", got)
|
||||||
|
}
|
||||||
|
if up := out(t, log, "rev-parse", "--abbrev-ref", "main@{upstream}"); up != "origin/main" {
|
||||||
|
t.Errorf("upstream = %q, want origin/main", up)
|
||||||
|
}
|
||||||
|
if out(t, log, "branch", "--list", "archive") != "" {
|
||||||
|
t.Error("archive must not be created")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRestoreCreatesARemoteFeatureBranch(t *testing.T) {
|
||||||
|
p, root := ownFixture(t)
|
||||||
|
super := filepath.Join(root, "super")
|
||||||
|
topic := filepath.Join(super, "libs", "topic")
|
||||||
|
mainTip := out(t, topic, "rev-parse", "main")
|
||||||
|
// libs/topic: detached at the tip of origin/feat/search, local main elsewhere
|
||||||
|
|
||||||
|
if got := restoreSub(t, p, super, "libs/topic"); got != "feat/search" {
|
||||||
|
t.Errorf("branch = %q, want feat/search", got)
|
||||||
|
}
|
||||||
|
if got := out(t, topic, "rev-parse", "main"); got != mainTip {
|
||||||
|
t.Errorf("main moved: %s -> %s", mainTip, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRestorePrefersLocalBranchesAlphabetically(t *testing.T) {
|
||||||
|
p, root := ownFixture(t)
|
||||||
|
super := filepath.Join(root, "super")
|
||||||
|
feature := filepath.Join(super, "libs", "feature")
|
||||||
|
// libs/feature: the pin is on no branch tip
|
||||||
|
git(t, feature, "branch", "zz")
|
||||||
|
git(t, feature, "branch", "aa")
|
||||||
|
|
||||||
|
if got := restoreSub(t, p, super, "libs/feature"); got != "aa" {
|
||||||
|
t.Errorf("branch = %q, want aa", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRestoreNeverMovesALocalBranchToAttach(t *testing.T) {
|
||||||
|
p, root := ownFixture(t)
|
||||||
|
super := filepath.Join(root, "super")
|
||||||
|
topic := filepath.Join(super, "libs", "topic")
|
||||||
|
git(t, topic, "branch", "feat/search", "origin/main") // same name, elsewhere
|
||||||
|
before := out(t, topic, "rev-parse", "feat/search")
|
||||||
|
|
||||||
|
if got := restoreSub(t, p, super, "libs/topic"); got != "" {
|
||||||
|
t.Errorf("branch = %q, want detached", got)
|
||||||
|
}
|
||||||
|
if after := out(t, topic, "rev-parse", "feat/search"); after != before {
|
||||||
|
t.Errorf("feat/search moved: %s -> %s", before, after)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRestoreKeepsTheCheckedOutBranch(t *testing.T) {
|
||||||
|
p, root := ownFixture(t)
|
||||||
|
super := filepath.Join(root, "super")
|
||||||
|
log := filepath.Join(super, "libs", "log")
|
||||||
|
git(t, log, "switch", "-q", "-c", "zz") // at the pin, like main
|
||||||
|
|
||||||
|
if got := restoreSub(t, p, super, "libs/log"); got != "zz" {
|
||||||
|
t.Errorf("branch = %q, want zz kept", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRestoreSkipsABranchOfAnotherWorktree(t *testing.T) {
|
||||||
|
p, root := ownFixture(t)
|
||||||
|
super := filepath.Join(root, "super")
|
||||||
|
log := filepath.Join(super, "libs", "log")
|
||||||
|
git(t, log, "checkout", "-q", "--detach")
|
||||||
|
git(t, log, "worktree", "add", "-q", filepath.Join(root, "wt"), "main")
|
||||||
|
|
||||||
|
if got := restoreSub(t, p, super, "libs/log"); got == "main" {
|
||||||
|
t.Error("main is checked out in another worktree")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRestoreSuperprojectAttachesNestedSubmodules(t *testing.T) {
|
||||||
|
p, root := ownFixture(t)
|
||||||
|
super := filepath.Join(root, "super")
|
||||||
|
nested := filepath.Join(super, "libs", "log", "modules", "nested")
|
||||||
|
git(t, nested, "checkout", "-q", "--detach")
|
||||||
|
|
||||||
|
if err := p.Restore(context.Background(), "", submodule.RestoreStaged); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := out(t, nested, "branch", "--show-current"); got != "main" {
|
||||||
|
t.Errorf("nested branch = %q, want main", got)
|
||||||
|
}
|
||||||
|
if got := out(t, filepath.Join(super, "libs", "topic"), "branch", "--show-current"); got != "feat/search" {
|
||||||
|
t.Errorf("libs/topic branch = %q, want feat/search", got)
|
||||||
|
}
|
||||||
|
if got := out(t, super, "branch", "--show-current"); got != "main" {
|
||||||
|
t.Errorf("superproject branch = %q, want main", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ func (p *Provider) Switch(ctx context.Context, path string, b submodule.Branch)
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := p.restoreRepo(ctx, dir, b.SHA); err != nil {
|
if err := p.restoreRepo(ctx, dir, b.SHA, nil); err != nil { // HEAD goes on b below
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user