mirror of
https://gitea.com/gitea/tea.git
synced 2026-10-26 19:11:25 +01:00
fix(git): refuse to sync a branch checked out in another worktree
Signed-off-by: bircni <bircni@icloud.com>
This commit is contained in:
2 files changed
+46
-1
No files matched your search
+28
-1
@@ -7,6 +7,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SyncOptions selects the local branch to fast-forward and the remote whose branch of the same name it follows.
|
// SyncOptions selects the local branch to fast-forward and the remote whose branch of the same name it follows.
|
||||||
@@ -29,7 +30,9 @@ type SyncResult struct {
|
|||||||
// SyncBranch fast-forwards the local branch to the remote's branch of the same name, creating it
|
// SyncBranch fast-forwards the local branch to the remote's branch of the same name, creating it
|
||||||
// when it is missing, and never changes which branch is checked out. The checked-out branch merges
|
// when it is missing, and never changes which branch is checked out. The checked-out branch merges
|
||||||
// with --ff-only and needs a clean working tree; another branch moves with update-ref, which fails
|
// with --ff-only and needs a clean working tree; another branch moves with update-ref, which fails
|
||||||
// if the branch changed meanwhile. A branch that already contains the remote history is kept.
|
// if the branch changed meanwhile. A branch that already contains the remote history is kept. A
|
||||||
|
// branch checked out in another linked worktree is refused, since moving it would leave that
|
||||||
|
// worktree's files behind its HEAD.
|
||||||
func SyncBranch(ctx context.Context, runner Runner, opts SyncOptions) (SyncResult, error) {
|
func SyncBranch(ctx context.Context, runner Runner, opts SyncOptions) (SyncResult, error) {
|
||||||
run := func(args ...string) (string, error) { return runner.Output(ctx, opts.Directory, opts.Prompt, args...) }
|
run := func(args ...string) (string, error) { return runner.Output(ctx, opts.Directory, opts.Prompt, args...) }
|
||||||
if opts.Remote == "" || opts.Branch == "" {
|
if opts.Remote == "" || opts.Branch == "" {
|
||||||
@@ -47,6 +50,15 @@ func SyncBranch(ctx context.Context, runner Runner, opts SyncOptions) (SyncResul
|
|||||||
return SyncResult{}, err
|
return SyncResult{}, err
|
||||||
}
|
}
|
||||||
checkedOut := head == ref
|
checkedOut := head == ref
|
||||||
|
if !checkedOut {
|
||||||
|
worktrees, err := run("worktree", "list", "--porcelain")
|
||||||
|
if err != nil {
|
||||||
|
return SyncResult{}, err
|
||||||
|
}
|
||||||
|
if path := worktreeWithBranch(worktrees, ref); path != "" {
|
||||||
|
return SyncResult{}, fmt.Errorf("%s is checked out in the worktree %s; sync it from there", opts.Branch, path)
|
||||||
|
}
|
||||||
|
}
|
||||||
if checkedOut {
|
if checkedOut {
|
||||||
status, err := run("status", "--porcelain=v1", "--untracked-files=normal")
|
status, err := run("status", "--porcelain=v1", "--untracked-files=normal")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -115,6 +127,21 @@ func SyncBranch(ctx context.Context, runner Runner, opts SyncOptions) (SyncResul
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// worktreeWithBranch returns the path of the worktree in git worktree list --porcelain output
|
||||||
|
// that has ref checked out, or "" when none has.
|
||||||
|
func worktreeWithBranch(porcelain, ref string) string {
|
||||||
|
path := ""
|
||||||
|
for line := range strings.Lines(porcelain) {
|
||||||
|
line = strings.TrimRight(line, "\r\n")
|
||||||
|
if value, ok := strings.CutPrefix(line, "worktree "); ok {
|
||||||
|
path = value
|
||||||
|
} else if line == "branch "+ref {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
// optional returns "" without an error for a Git command that exits with status 1 to report
|
// optional returns "" without an error for a Git command that exits with status 1 to report
|
||||||
// that what it looked for does not exist, such as rev-parse --verify --quiet.
|
// that what it looked for does not exist, such as rev-parse --verify --quiet.
|
||||||
func optional(output string, err error) (string, error) {
|
func optional(output string, err error) (string, error) {
|
||||||
|
|||||||
@@ -158,6 +158,24 @@ func TestSyncBranchRefusesWhatItCannotFastForward(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSyncBranchRefusesABranchCheckedOutInAnotherWorktree(t *testing.T) {
|
||||||
|
f := newSyncFixture(t)
|
||||||
|
linked := filepath.Join(t.TempDir(), "linked")
|
||||||
|
f.git(f.work, "worktree", "add", linked, "main")
|
||||||
|
before := f.git(f.work, "rev-parse", "main")
|
||||||
|
_, err := f.sync("main")
|
||||||
|
require.ErrorContains(t, err, "main is checked out in the worktree")
|
||||||
|
require.Equal(t, before, f.git(f.work, "rev-parse", "main"))
|
||||||
|
require.Equal(t, "experiment", f.git(f.work, "branch", "--show-current"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWorktreeWithBranch(t *testing.T) {
|
||||||
|
porcelain := "worktree /src\nHEAD abc\nbranch refs/heads/experiment\n\nworktree /linked\nHEAD def\nbranch refs/heads/main\n\nworktree /detached\nHEAD 123\ndetached\n"
|
||||||
|
require.Equal(t, "/linked", worktreeWithBranch(porcelain, "refs/heads/main"))
|
||||||
|
require.Equal(t, "/src", worktreeWithBranch(porcelain, "refs/heads/experiment"))
|
||||||
|
require.Empty(t, worktreeWithBranch(porcelain, "refs/heads/other"))
|
||||||
|
}
|
||||||
|
|
||||||
func TestSyncBranchValidatesItsInput(t *testing.T) {
|
func TestSyncBranchValidatesItsInput(t *testing.T) {
|
||||||
f := newSyncFixture(t)
|
f := newSyncFixture(t)
|
||||||
_, err := SyncBranch(context.Background(), f.runner, SyncOptions{Directory: f.work, Remote: "origin"})
|
_, err := SyncBranch(context.Background(), f.runner, SyncOptions{Directory: f.work, Remote: "origin"})
|
||||||
|
|||||||
Reference in new issue
Block a user