diff --git a/pkg/app/absorb.go b/pkg/app/absorb.go new file mode 100644 index 0000000..de99a7c --- /dev/null +++ b/pkg/app/absorb.go @@ -0,0 +1,401 @@ +package app + +import ( + "context" + "errors" + "fmt" + "slices" + "strings" + + "github.com/DomBlack/git-stack/pkg/absorb" + "github.com/DomBlack/git-stack/pkg/git" + "github.com/DomBlack/git-stack/pkg/stack" +) + +// AbsorbOptions mirrors `gt absorb`. +type AbsorbOptions struct { + Staging StagingMode + // DryRun plans and shows where everything would go, changing no commit. + DryRun bool + // Force skips the confirmation on a terminal. + Force bool + // Preview, when set, is shown the plan before anything is asked or + // changed. The CLI renders it; the MCP tool reads it off the result. + Preview func(*AbsorbPlan) +} + +// AbsorbTarget is the commit a hunk goes into. +type AbsorbTarget struct { + SHA string `json:"sha"` + Subject string `json:"subject"` + Branch string `json:"branch"` +} + +// AbsorbHunk is one staged run of changed lines and where it goes. +type AbsorbHunk struct { + Path string `json:"path"` + // OldStart and OldCount locate the lines in HEAD; an insertion has + // OldCount 0 and goes after line OldStart. + OldStart int `json:"old_start"` + OldCount int `json:"old_count"` + NewCount int `json:"new_count"` + // Target is nil when no commit in the stack changed those lines; the + // hunk stays staged. + Target *AbsorbTarget `json:"target,omitempty"` +} + +// AbsorbSkipped is a staged file absorb never plans, and why. +type AbsorbSkipped struct { + Path string `json:"path"` + Reason string `json:"reason"` +} + +// AbsorbPlan is where every staged change goes. +type AbsorbPlan struct { + Hunks []AbsorbHunk `json:"hunks"` + Skipped []AbsorbSkipped `json:"skipped,omitempty"` + // Absorbed and Unabsorbed count hunks; Commits counts the distinct + // destination commits. + Absorbed int `json:"absorbed"` + Unabsorbed int `json:"unabsorbed"` + Commits int `json:"commits"` +} + +// AbsorbResult reports what absorb did. +type AbsorbResult struct { + Branch string `json:"branch"` + Plan AbsorbPlan `json:"plan"` + DryRun bool `json:"dry_run"` + // Applied is false on a dry run, when there was nothing to absorb or + // when the user said no. + Applied bool `json:"applied"` + // Rewritten lists the branches that moved, bottom to top. + Rewritten []BranchMove `json:"rewritten,omitempty"` + // Restacked lists the branches above that were rebased afterwards. + Restacked []string `json:"restacked,omitempty"` + Notices []string `json:"notices,omitempty"` +} + +const absorbCommand = "git stack absorb" + +// syncSteps is what to do when the stack's metadata names a branch that is gone. +const syncSteps = "run git stack sync to tidy the stack's metadata" + +// Absorb folds each staged hunk into the commit of the current stack that +// last changed those lines, rewrites that commit and everything above it +// up to the current branch, and restacks the branches above. +func (a *App) Absorb(ctx context.Context, repo git.Repo, o AbsorbOptions) (AbsorbResult, error) { + if err := a.noRebaseActive(ctx, repo); err != nil { + return AbsorbResult{}, err + } + current, err := a.d.Git.CurrentBranch(ctx, repo) + if err != nil { + if errors.Is(err, git.ErrDetached) { + return AbsorbResult{}, stack.New(stack.KindInvalidArgs, "HEAD is detached; check out a stacked branch first") + } + return AbsorbResult{}, err + } + graph, err := a.d.Meta.Load(ctx, repo) + if err != nil { + return AbsorbResult{}, err + } + if graph.IsTrunk(current) { + return AbsorbResult{}, stack.Newf(stack.KindInvalidArgs, "%s is a trunk; absorb works on stacked branches", current) + } + s, i, ok := graph.StackOf(current) + if !ok { + return AbsorbResult{}, stack.Newf(stack.KindNotInStack, "%s is not in a stack", current) + } + if s.Branches[i].Merged() { + return AbsorbResult{}, stack.Newf(stack.KindInvalidArgs, "%s's pull request has merged, so its commits can't be rewritten", current). + WithSteps("run git stack sync to clean it up") + } + staged, err := a.stage(ctx, repo, o.Staging) + if err != nil { + return AbsorbResult{}, err + } + if !staged { + return AbsorbResult{}, stack.New(stack.KindInvalidArgs, "nothing to absorb: no staged changes"). + WithSteps("stage the changes to absorb (or use -a / -u / -p)") + } + run, err := a.absorbCandidates(ctx, repo, s, i) + if err != nil { + return AbsorbResult{}, err + } + diff, err := a.d.Git.StagedPatch(ctx, repo) + if err != nil { + return AbsorbResult{}, err + } + files, err := absorb.Parse(diff) + if err != nil { + return AbsorbResult{}, fmt.Errorf("reading the staged diff: %w", err) + } + run.plan = absorb.Compute(run.commits, files) + res := AbsorbResult{Branch: current, Plan: absorbPlanOf(&run.plan), DryRun: o.DryRun} + if o.Preview != nil { + o.Preview(&res.Plan) + } + if res.Plan.Absorbed == 0 || o.DryRun { + return res, nil + } + if !o.Force && a.d.Prompter != nil { + q := fmt.Sprintf("Absorb %d %s into %d %s?", res.Plan.Absorbed, pluralise(res.Plan.Absorbed, "hunk", "hunks"), res.Plan.Commits, pluralise(res.Plan.Commits, "commit", "commits")) + yes, err := a.d.Prompter.Confirm(q, true) + if err != nil { + return res, err + } + if !yes { + res.Notices = append(res.Notices, "nothing was absorbed") + return res, nil + } + } + return a.absorbApply(ctx, repo, run, res) +} + +// absorbRun is one absorb in flight: the stack, the candidate commits +// oldest first, the tips of the candidate branches, and the plan. +type absorbRun struct { + stack *stack.Stack + current int // index of the current branch in stack.Branches + lowest int // index of the lowest candidate branch + commits []absorb.Commit + local map[string]git.Branch + plan absorb.Plan +} + +// absorbCandidates collects the commits a hunk may go into: those of the +// current branch and the branches below it, stopping before the first +// branch whose pull request has merged. Every candidate branch but the +// bottom of the stack must sit on its parent; the bottom may be behind +// trunk, and its own commits are found the way restack finds them: from the +// trunk tip when the branch already contains it (so a branch rebased onto a +// newer trunk by hand doesn't claim trunk's commits), otherwise from its +// recorded base, otherwise from the merge base with trunk. +func (a *App) absorbCandidates(ctx context.Context, repo git.Repo, s *stack.Stack, i int) (*absorbRun, error) { + local, err := a.localBranches(ctx, repo) + if err != nil { + return nil, err + } + lo := i + for lo > 0 && !s.Branches[lo-1].Merged() { + lo-- + } + run := &absorbRun{stack: s, current: i, lowest: lo, local: local} + for j := lo; j <= i; j++ { + b := s.Branches[j] + lb, ok := local[b.Name] + if !ok { + return nil, stack.Newf(stack.KindInvalidArgs, "%s no longer exists", b.Name).WithSteps(syncSteps) + } + var from string + if j == 0 { + trunk, ok := local[s.Trunk] + if !ok { + return nil, stack.Newf(stack.KindInvalidArgs, "trunk %s no longer exists", s.Trunk).WithSteps(syncSteps) + } + switch { + case a.isAncestor(ctx, repo, trunk.Head, lb.Head): + from = trunk.Head + case b.Base != "" && a.isAncestor(ctx, repo, b.Base, lb.Head): + from = b.Base + default: + if from, err = a.d.Git.MergeBase(ctx, repo, lb.Head, trunk.Head); err != nil { + return nil, err + } + } + } else { + parent := s.Branches[j-1].Name + pb, ok := local[parent] + if !ok { + return nil, stack.Newf(stack.KindInvalidArgs, "%s no longer exists", parent).WithSteps(syncSteps) + } + if !a.isAncestor(ctx, repo, pb.Head, lb.Head) { + return nil, stack.Newf(stack.KindInvalidArgs, "%s needs restacking onto %s before its commits can be rewritten", b.Name, parent). + WithSteps("git stack restack, then " + absorbCommand + " again") + } + from = pb.Head + } + if merges, err := a.d.Git.HasMerges(ctx, repo, from, lb.Head); err != nil { + return nil, err + } else if merges { + return nil, stack.Newf(stack.KindInvalidArgs, "%s has a merge commit, which absorb can't rewrite", b.Name). + WithSteps("fold the merge into plain commits first") + } + commits, err := a.d.Git.Commits(ctx, repo, lb.Head, from) + if err != nil { + return nil, err + } + for _, c := range slices.Backward(commits) { // oldest first + diff, err := a.d.Git.DiffCommit(ctx, repo, c.SHA) + if err != nil { + return nil, err + } + files, err := absorb.Parse(diff) + if err != nil { + return nil, fmt.Errorf("reading %s: %w", c.SHA, err) + } + run.commits = append(run.commits, absorb.Commit{SHA: c.SHA, Subject: c.Subject, Branch: b.Name, Files: files}) + } + } + return run, nil +} + +// absorbPlanOf is the plan as a result carries it. +func absorbPlanOf(p *absorb.Plan) AbsorbPlan { + out := AbsorbPlan{Hunks: []AbsorbHunk{}, Absorbed: p.Absorbed(), Unabsorbed: p.Unabsorbed(), Commits: p.Targets()} + for _, h := range p.Hunks { + ah := AbsorbHunk{Path: h.Path, OldStart: h.Hunk.OldStart, OldCount: h.Hunk.OldCount, NewCount: h.Hunk.NewCount} + if h.Target != nil { + ah.Target = &AbsorbTarget{SHA: h.Target.SHA, Subject: h.Target.Subject, Branch: h.Target.Branch} + } + out.Hunks = append(out.Hunks, ah) + } + for _, s := range p.Skipped { + out.Skipped = append(out.Skipped, AbsorbSkipped{Path: s.Path, Reason: s.Reason}) + } + return out +} + +// absorbApply writes the rewritten commits, moves the affected branches +// in one ref transaction, updates the metadata and restacks what sits +// above the current branch. res comes back with Applied set as soon as +// the branches have moved, so a conflict in the restack afterwards still +// reports the absorb. +func (a *App) absorbApply(ctx context.Context, repo git.Repo, run *absorbRun, res AbsorbResult) (AbsorbResult, error) { + rewritten := map[string]bool{} + for _, sha := range run.plan.Rewrites { + rewritten[sha] = true + } + subjects := map[string]string{} + for _, c := range run.commits { + subjects[c.SHA] = c.Subject + } + // The branches that move: every candidate whose tip is rewritten, + // which is the contiguous run from the oldest destination's branch up + // to the current one. + type move struct { + name, from, worktree string + } + var moves []move + for j := run.lowest; j <= run.current; j++ { + lb := run.local[run.stack.Branches[j].Name] + if rewritten[lb.Head] { + moves = append(moves, move{name: lb.Name, from: lb.Head, worktree: lb.Worktree}) + } + } + // A checkout elsewhere with changes can't be brought along, and the + // branches above depend on this one moving, so refuse before any work. + dirty := newDirtyCache() + for _, m := range moves { + if m.worktree != "" && m.worktree != repo.TopLevel && dirty.isDirty(ctx, a.d.Git, repo, m.worktree) { + return res, stack.Newf(stack.KindInvalidArgs, "%s is checked out in %s with uncommitted changes, so it can't be rewritten", m.name, shortPath(m.worktree)). + WithSteps("commit or stash them there, then " + absorbCommand + " again") + } + } + + n := res.Plan.Absorbed + headline := fmt.Sprintf("Absorbing %d %s into %d %s", n, pluralise(n, "hunk", "hunks"), res.Plan.Commits, pluralise(res.Plan.Commits, "commit", "commits")) + err := a.progress(ctx, PhaseAbsorb, headline, func(ctx context.Context) error { + // 1. Objects only: every rewritten commit, oldest first, on the + // previous rewritten one. + newSHA := map[string]string{} + parent := "" + for _, sha := range run.plan.Rewrites { + info, err := a.d.Git.CommitInfo(ctx, repo, sha) + if err != nil { + return err + } + if parent == "" { + parent = info.Parent + } + tree, err := a.d.Git.PatchTree(ctx, repo, info.Tree, absorb.PatchFiles(run.plan.Patches[sha])) + if err != nil { + if pe, ok := errors.AsType[*git.PatchError](err); ok { + return stack.Newf(stack.KindInvalidArgs, "the change for %q could not be applied to %s; nothing was changed", subjects[sha], sha[:7]). + WithSteps("this is a bug in " + absorbCommand + "; please report it with the detail above").WithDetail(pe.Detail).WithCause(err) + } + return err + } + if parent, err = a.d.Git.CommitTree(ctx, repo, tree, parent, info); err != nil { + return err + } + newSHA[sha] = parent + } + // 2. Every affected branch in one transaction, the current one + // included: moving its ref leaves the index and working tree as + // they are, so the absorbed hunks simply drop out of the staged + // diff. + updates := make([]git.RefUpdate, len(moves)) + for i, m := range moves { + updates[i] = git.RefUpdate{Ref: "refs/heads/" + m.name, New: newSHA[m.from], Old: m.from} + } + if err := a.d.Git.UpdateRefs(ctx, repo, updates); err != nil { + if git.LockPath(err) != "" { + return stack.New(stack.KindLocked, "the branches could not be moved: "+err.Error()). + WithSteps("run " + absorbCommand + " again").WithCause(err) + } + return stack.New(stack.KindInvalidArgs, "a branch moved while absorbing, so nothing was changed"). + WithSteps("run " + absorbCommand + " again").WithCause(err) + } + res.Applied = true + for _, m := range moves { + res.Rewritten = append(res.Rewritten, BranchMove{Name: m.name, Stack: run.stack.Bottom(), From: m.from, To: newSHA[m.from]}) + } + // 3. Clean checkouts elsewhere follow their branch. + for _, m := range moves { + if m.worktree != "" && m.worktree != repo.TopLevel { + if err := a.d.Git.ResetHard(ctx, worktreeRepo(repo, m.worktree), newSHA[m.from]); err != nil { + res.Notices = append(res.Notices, fmt.Sprintf("%s was rewritten but its checkout in %s could not be updated: %v; run git reset --hard there", m.name, shortPath(m.worktree), err)) + } + } + } + // 4. Heads for every moved branch; bases for the ones above the + // lowest (their parent moved too). The lowest keeps its base. + heads, bases := map[string]string{}, map[string]string{} + for i, m := range moves { + heads[m.name] = newSHA[m.from] + if i > 0 { + bases[m.name] = newSHA[moves[i-1].from] + } + } + return a.updateHeadsAndBases(ctx, repo, heads, bases) + }) + if err != nil { + return res, err + } + // 5. Everything above, the way modify does it. + children := run.stack.Names()[run.current+1:] // linear stacks: everything above is a descendant + if len(children) == 0 { + return res, nil + } + k := len(children) + rr, err := a.restack(ctx, repo, RestackOptions{Scope: stack.ScopeUpstack}, restackCommand, + fmt.Sprintf("Restacking %d %s above %s", k, pluralise(k, "branch", "branches"), res.Branch)) + res.Notices = append(res.Notices, rr.Notices...) + res.Restacked = movedNames(rr) + left := make([]string, 0, len(children)) + for _, name := range children { + if !slices.Contains(res.Restacked, name) { + left = append(left, name) + } + } + if se, ok := errors.AsType[*stack.Error](err); ok && se.Kind != stack.KindConflict && len(left) > 0 { + // The absorb is done; say what is left, since re-running absorb + // would not restack anything. + isAre := pluralise(len(left), "was", "were") + steps := make([]string, len(se.NextSteps)) + for i, step := range se.NextSteps { + steps[i] = strings.ReplaceAll(step, "then try again", "then "+restackCommand) + } + err = &stack.Error{ + Kind: se.Kind, + Msg: fmt.Sprintf("absorbed, but %s above %s %s not restacked: %s", joinNames(left), res.Branch, isAre, se.Msg), + NextSteps: steps, + Files: se.Files, + Branch: se.Branch, + Detail: se.Detail, + Cause: err, + } + } + return res, err +} diff --git a/pkg/app/absorb_test.go b/pkg/app/absorb_test.go new file mode 100644 index 0000000..653a700 --- /dev/null +++ b/pkg/app/absorb_test.go @@ -0,0 +1,662 @@ +package app_test + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "slices" + "strings" + "testing" + + "github.com/DomBlack/git-stack/pkg/app" + "github.com/DomBlack/git-stack/pkg/exec" + "github.com/DomBlack/git-stack/pkg/git" + "github.com/DomBlack/git-stack/pkg/git/gittest" + "github.com/DomBlack/git-stack/pkg/stack" +) + +// numbered is twenty numbered lines, the file every absorb test edits. +func numbered() string { + var b strings.Builder + for i := 1; i <= 20; i++ { + fmt.Fprintf(&b, "%d\n", i) + } + return b.String() +} + +// withLine replaces line n (1 based) of numbered() with text. +func withLine(base string, n int, text string) string { + lines := strings.Split(strings.TrimSuffix(base, "\n"), "\n") + lines[n-1] = text + return strings.Join(lines, "\n") + "\n" +} + +// absorbFixture: main (f.txt numbered 1..20) -> a (line 5 -> five, adds +// a.txt) -> b (line 15 -> fifteen) -> c (adds c.txt), tracked with heads and +// bases recorded, HEAD on b. +type absorbFixture struct { + app *app.App + fb *fakeBackend + deps app.Deps + repo git.Repo + dir string +} + +func newAbsorbFixture(t *testing.T) *absorbFixture { + t.Helper() + gittest.Isolate(t) + dir := gittest.InitRepo(t) + gittest.Commit(t, dir, "f.txt", numbered(), "numbers") + gittest.Run(t, dir, "switch", "-q", "-c", "a") + gittest.Commit(t, dir, "f.txt", withLine(numbered(), 5, "five"), "feat: a") + gittest.Commit(t, dir, "a.txt", "a\n", "feat: a file") + gittest.Run(t, dir, "switch", "-q", "-c", "b") + gittest.Commit(t, dir, "f.txt", withLine(withLine(numbered(), 5, "five"), 15, "fifteen"), "feat: b") + gittest.Run(t, dir, "switch", "-q", "-c", "c") + gittest.Commit(t, dir, "c.txt", "c\n", "feat: c") + gittest.Run(t, dir, "switch", "-q", "b") + g := git.New(exec.New()) + repo, err := g.Discover(context.Background(), dir) + if err != nil { + t.Fatal(err) + } + rev := func(r string) string { return gittest.Run(t, dir, "rev-parse", r) } + fb := &fakeBackend{git: g, graph: stack.NewGraph([]stack.Stack{{Trunk: "main", Branches: []stack.Branch{ + {Name: "a", Head: rev("a"), Base: rev("main")}, + {Name: "b", Head: rev("b"), Base: rev("a")}, + {Name: "c", Head: rev("c"), Base: rev("b")}, + }}})} + deps := app.Deps{Git: g, Meta: fb, Tracker: fb} + return &absorbFixture{app: app.New(deps), fb: fb, deps: deps, repo: repo, dir: dir} +} + +func (f *absorbFixture) rev(t *testing.T, r string) string { + t.Helper() + return gittest.Run(t, f.dir, "rev-parse", r) +} + +// stageLine stages f.txt with line n replaced by text (on top of whatever +// the working tree has). +func (f *absorbFixture) stageLine(t *testing.T, n int, text string) { + t.Helper() + // Builds on the index, so several calls accumulate staged edits. + cur := gittest.Run(t, f.dir, "show", ":f.txt") + "\n" + gittest.WriteFile(t, f.dir, "f.txt", withLine(cur, n, text)) + gittest.Run(t, f.dir, "add", "f.txt") +} + +func targetsOf(p app.AbsorbPlan) []string { + var out []string + for _, h := range p.Hunks { + if h.Target == nil { + out = append(out, "none") + } else { + out = append(out, h.Target.Branch) + } + } + return out +} + +func TestAbsorbPlansEachHunk(t *testing.T) { + f := newAbsorbFixture(t) + ctx := context.Background() + f.stageLine(t, 5, "FIVE") + f.stageLine(t, 15, "FIFTEEN") + f.stageLine(t, 1, "one") + gittest.WriteFile(t, f.dir, "new.txt", "new\n") + gittest.Run(t, f.dir, "add", "new.txt") + + var previewed *app.AbsorbPlan + res, err := f.app.Absorb(ctx, f.repo, app.AbsorbOptions{DryRun: true, Preview: func(p *app.AbsorbPlan) { previewed = p }}) + if err != nil { + t.Fatal(err) + } + if !res.DryRun || res.Applied || res.Branch != "b" || previewed == nil { + t.Errorf("res = %+v", res) + } + if got := targetsOf(res.Plan); !slices.Equal(got, []string{"none", "a", "b"}) { + t.Errorf("targets = %v (hunks %+v)", got, res.Plan.Hunks) + } + h := res.Plan.Hunks[1] + if h.Path != "f.txt" || h.OldStart != 5 || h.OldCount != 1 || h.NewCount != 1 || h.Target.SHA != f.rev(t, "a~1") || h.Target.Subject != "feat: a" { + t.Errorf("hunk = %+v", h) + } + if len(res.Plan.Skipped) != 1 || res.Plan.Skipped[0].Path != "new.txt" || res.Plan.Skipped[0].Reason != "new file" { + t.Errorf("skipped = %+v", res.Plan.Skipped) + } + if res.Plan.Absorbed != 2 || res.Plan.Unabsorbed != 1 || res.Plan.Commits != 2 { + t.Errorf("counts = %+v", res.Plan) + } + // A dry run changes no ref. + if f.rev(t, "a") != f.fb.graph.Stacks[0].Branches[0].Head || f.rev(t, "b") != f.fb.graph.Stacks[0].Branches[1].Head { + t.Error("dry run moved a branch") + } +} + +func TestAbsorbStagesFirstEvenOnADryRun(t *testing.T) { + f := newAbsorbFixture(t) + gittest.WriteFile(t, f.dir, "f.txt", withLine(gittest.Run(t, f.dir, "show", "HEAD:f.txt")+"\n", 5, "FIVE")) + res, err := f.app.Absorb(context.Background(), f.repo, app.AbsorbOptions{DryRun: true, Staging: app.StageUpdate}) + if err != nil || res.Plan.Absorbed != 1 { + t.Fatalf("res = %+v %v", res, err) + } + if out := gittest.Run(t, f.dir, "diff", "--cached", "--name-only"); out != "f.txt" { + t.Errorf("-u should have staged f.txt, got %q", out) + } +} + +func TestAbsorbRefusals(t *testing.T) { + f := newAbsorbFixture(t) + ctx := context.Background() + wantKind := func(what string, err error, kind stack.Kind) { + t.Helper() + if !errors.Is(err, &stack.Error{Kind: kind}) { + t.Errorf("%s: got %v, want %s", what, err, kind) + } + } + _, err := f.app.Absorb(ctx, f.repo, app.AbsorbOptions{}) + wantKind("nothing staged", err, stack.KindInvalidArgs) + + // A new file survives the branch switches below, which an edit to f.txt + // (different on main) would not. + gittest.WriteFile(t, f.dir, "x.txt", "x\n") + gittest.Run(t, f.dir, "add", "x.txt") + gittest.Run(t, f.dir, "switch", "-q", "main") + _, err = f.app.Absorb(ctx, f.repo, app.AbsorbOptions{}) + wantKind("trunk", err, stack.KindInvalidArgs) + if !strings.Contains(err.Error(), "trunk") { + t.Errorf("trunk message = %q", err.Error()) + } + gittest.Run(t, f.dir, "switch", "-q", "-c", "loose", "b") + _, err = f.app.Absorb(ctx, f.repo, app.AbsorbOptions{}) + wantKind("untracked branch", err, stack.KindNotInStack) + gittest.Run(t, f.dir, "switch", "-q", "--detach") + _, err = f.app.Absorb(ctx, f.repo, app.AbsorbOptions{}) + wantKind("detached", err, stack.KindInvalidArgs) + gittest.Run(t, f.dir, "switch", "-q", "b") + + // b's own pull request has merged: nothing to rewrite. + f.fb.graph.Stacks[0].Branches[1].PR = &stack.PRRef{Number: 2, Merged: true} + _, err = f.app.Absorb(ctx, f.repo, app.AbsorbOptions{}) + wantKind("merged current", err, stack.KindInvalidArgs) + if se, ok := errors.AsType[*stack.Error](err); !ok || !slices.ContainsFunc(se.NextSteps, func(s string) bool { return strings.Contains(s, "git stack sync") }) { + t.Errorf("merged current steps = %v", err) + } + f.fb.graph.Stacks[0].Branches[1].PR = nil + + // a moved under b (amended by hand), so b needs restacking first. + gittest.Run(t, f.dir, "switch", "-q", "--detach", "main") + other := gittest.Commit(t, f.dir, "other.txt", "other\n", "a, amended") + gittest.Run(t, f.dir, "switch", "-q", "b") + gittest.Run(t, f.dir, "update-ref", "refs/heads/a", other) + // The commit above took the staged file with it; stage again. + gittest.WriteFile(t, f.dir, "y.txt", "y\n") + gittest.Run(t, f.dir, "add", "y.txt") + _, err = f.app.Absorb(ctx, f.repo, app.AbsorbOptions{}) + wantKind("needs restack", err, stack.KindInvalidArgs) + if !strings.Contains(err.Error(), "needs restacking") { + t.Errorf("message = %q", err.Error()) + } + if se, ok := errors.AsType[*stack.Error](err); !ok || !slices.ContainsFunc(se.NextSteps, func(s string) bool { return strings.Contains(s, "git stack restack") }) { + t.Errorf("needs restack steps = %v", err) + } + gittest.Run(t, f.dir, "update-ref", "refs/heads/a", f.fb.graph.Stacks[0].Branches[0].Head) +} + +func TestAbsorbRefusesWhileARebaseIsInProgress(t *testing.T) { + f := newAbsorbFixture(t) + gittest.Run(t, f.dir, "switch", "-q", "-c", "side", "main") + gittest.Commit(t, f.dir, "f.txt", withLine(numbered(), 5, "side"), "side edit") + gittest.Run(t, f.dir, "switch", "-q", "b") + if _, err := gittest.RunErr(t, f.dir, "rebase", "side"); err == nil { + t.Fatal("the rebase should have stopped on a conflict") + } + t.Cleanup(func() { _, _ = gittest.RunErr(t, f.dir, "rebase", "--abort") }) + gittest.WriteFile(t, f.dir, "x.txt", "x\n") + gittest.Run(t, f.dir, "add", "x.txt") + _, err := f.app.Absorb(context.Background(), f.repo, app.AbsorbOptions{}) + if !errors.Is(err, &stack.Error{Kind: stack.KindRebaseActive}) { + t.Errorf("err = %v", err) + } +} + +func TestAbsorbCurrentBranchWithNoCommitsOfItsOwn(t *testing.T) { + f := newAbsorbFixture(t) + tip := f.rev(t, "c") + gittest.Run(t, f.dir, "switch", "-q", "-c", "d", "c") + f.fb.graph.Stacks[0].Branches = append(f.fb.graph.Stacks[0].Branches, stack.Branch{Name: "d", Head: tip, Base: tip}) + f.stageLine(t, 15, "FIFTEEN") + res, err := f.app.Absorb(context.Background(), f.repo, app.AbsorbOptions{}) + if err != nil || !res.Applied { + t.Fatalf("res = %+v %v", res, err) + } + var names []string + for _, m := range res.Rewritten { + names = append(names, m.Name) + } + if !slices.Equal(names, []string{"b", "c", "d"}) { + t.Errorf("rewritten = %v", names) + } + if f.rev(t, "d") != f.rev(t, "c") || f.rev(t, "c") == tip { + t.Errorf("d = %s, c = %s, old c = %s", f.rev(t, "d"), f.rev(t, "c"), tip) + } + br := f.fb.graph.Stacks[0].Branches + if br[3].Name != "d" || br[3].Base != f.rev(t, "c") || br[3].Head != f.rev(t, "d") { + t.Errorf("d's metadata = %+v", br[3]) + } +} + +func TestAbsorbRewritesThreeBranches(t *testing.T) { + f := newAbsorbFixture(t) + mainTip := f.rev(t, "main") + gittest.Run(t, f.dir, "switch", "-q", "c") + f.stageLine(t, 5, "FIVE") + res, err := f.app.Absorb(context.Background(), f.repo, app.AbsorbOptions{}) + if err != nil || !res.Applied { + t.Fatalf("res = %+v %v", res, err) + } + var names []string + for _, m := range res.Rewritten { + names = append(names, m.Name) + } + if !slices.Equal(names, []string{"a", "b", "c"}) { + t.Errorf("rewritten = %v", names) + } + br := f.fb.graph.Stacks[0].Branches + if br[1].Base != f.rev(t, "a") || br[2].Base != f.rev(t, "b") || br[0].Base != mainTip { + t.Errorf("bases = %q %q %q", br[0].Base, br[1].Base, br[2].Base) + } + if got := gittest.Run(t, f.dir, "show", "c:f.txt"); !strings.Contains(got, "\nFIVE\n") { + t.Errorf("c = %q", got) + } +} + +func TestAbsorbRefusesMergeCommits(t *testing.T) { + f := newAbsorbFixture(t) + // b gains a merge commit of a side branch. + gittest.Run(t, f.dir, "switch", "-q", "-c", "side", "a") + gittest.Commit(t, f.dir, "s.txt", "s\n", "side") + gittest.Run(t, f.dir, "switch", "-q", "b") + gittest.Run(t, f.dir, "merge", "-q", "--no-ff", "-m", "merge side", "side") + f.fb.graph.Stacks[0].Branches[1].Head = f.rev(t, "b") + f.stageLine(t, 15, "FIFTEEN") + _, err := f.app.Absorb(context.Background(), f.repo, app.AbsorbOptions{}) + if !errors.Is(err, &stack.Error{Kind: stack.KindInvalidArgs}) || !strings.Contains(err.Error(), "merge commit") { + t.Errorf("err = %v", err) + } +} + +func TestAbsorbMergedBranchBelowIsNotACandidate(t *testing.T) { + f := newAbsorbFixture(t) + f.fb.graph.Stacks[0].Branches[0].PR = &stack.PRRef{Number: 1, Merged: true} + f.stageLine(t, 5, "FIVE") + f.stageLine(t, 15, "FIFTEEN") + res, err := f.app.Absorb(context.Background(), f.repo, app.AbsorbOptions{DryRun: true}) + if err != nil { + t.Fatal(err) + } + if got := targetsOf(res.Plan); !slices.Equal(got, []string{"none", "b"}) { + t.Errorf("targets = %v", got) + } +} + +func TestAbsorbNothingToAbsorbAndDeclined(t *testing.T) { + f := newAbsorbFixture(t) + ctx := context.Background() + f.stageLine(t, 1, "one") + res, err := f.app.Absorb(ctx, f.repo, app.AbsorbOptions{}) + if err != nil || res.Applied || res.Plan.Absorbed != 0 || res.Plan.Unabsorbed != 1 { + t.Errorf("nothing absorbable: %+v %v", res, err) + } + // On a terminal the question is asked; no means nothing happens. + p := &fakePrompter{confirm: false} + f.deps.Prompter = p + a := app.New(f.deps) + f.stageLine(t, 5, "FIVE") + res, err = a.Absorb(ctx, f.repo, app.AbsorbOptions{}) + if err != nil || res.Applied || len(p.asked) != 1 || !strings.Contains(p.asked[0], "Absorb 1 hunk into 1 commit?") { + t.Errorf("declined: %+v %v asked %q", res, err, p.asked) + } + if len(res.Notices) == 0 || f.rev(t, "a") != f.fb.graph.Stacks[0].Branches[0].Head { + t.Errorf("declined should change nothing: %+v", res) + } +} + +func TestAbsorbBottomBranchRebasedByHandOntoNewerTrunk(t *testing.T) { + f := newAbsorbFixture(t) + ctx := context.Background() + // main gains a commit that changes line 10, then a, b and c are rebased + // onto it with plain git. The recorded bases stay stale. + gittest.Run(t, f.dir, "switch", "-q", "main") + gittest.Commit(t, f.dir, "f.txt", withLine(numbered(), 10, "ten"), "trunk: ten") + gittest.Run(t, f.dir, "rebase", "-q", "main", "a") + gittest.Run(t, f.dir, "rebase", "-q", "--onto", "a", f.fb.graph.Stacks[0].Branches[0].Head, "b") + gittest.Run(t, f.dir, "rebase", "-q", "--onto", "b", f.fb.graph.Stacks[0].Branches[1].Head, "c") + gittest.Run(t, f.dir, "switch", "-q", "b") + for i := range f.fb.graph.Stacks[0].Branches { + br := &f.fb.graph.Stacks[0].Branches[i] + br.Head = f.rev(t, br.Name) + } + + f.stageLine(t, 10, "TEN") + f.stageLine(t, 5, "FIVE") + res, err := f.app.Absorb(ctx, f.repo, app.AbsorbOptions{DryRun: true}) + if err != nil { + t.Fatal(err) + } + if got := targetsOf(res.Plan); !slices.Equal(got, []string{"a", "none"}) && !slices.Equal(got, []string{"none", "a"}) { + t.Fatalf("targets = %v", got) + } + for _, h := range res.Plan.Hunks { + switch h.OldStart { + case 5: + if h.Target == nil || h.Target.Branch != "a" { + t.Errorf("line 5 should target a: %+v", h) + } + case 10: + if h.Target != nil { + t.Errorf("line 10 was changed by trunk, so it should not be absorbed: %+v", h) + } + } + } +} + +func TestAbsorbIntoBottomBranchRewritesEverythingAbove(t *testing.T) { + f := newAbsorbFixture(t) + ctx := context.Background() + oldA, oldB, oldC := f.rev(t, "a"), f.rev(t, "b"), f.rev(t, "c") + authorsBefore := gittest.Run(t, f.dir, "log", "--format=%an <%ae> %at", "main..b") + f.stageLine(t, 5, "FIVE") + f.stageLine(t, 15, "FIFTEEN") + f.stageLine(t, 1, "one") + gittest.WriteFile(t, f.dir, "new.txt", "new\n") + gittest.Run(t, f.dir, "add", "new.txt") + gittest.WriteFile(t, f.dir, "a.txt", "unstaged edit\n") + + res, err := f.app.Absorb(ctx, f.repo, app.AbsorbOptions{}) + if err != nil { + t.Fatal(err) + } + if !res.Applied || res.DryRun || len(res.Rewritten) != 2 || res.Rewritten[0].Name != "a" || res.Rewritten[0].From != oldA || res.Rewritten[1].Name != "b" || res.Rewritten[1].From != oldB { + t.Errorf("res = %+v", res) + } + if !slices.Equal(res.Restacked, []string{"c"}) { + t.Errorf("restacked = %v", res.Restacked) + } + // The hunks landed in the right commits and were carried above. + if got := gittest.Run(t, f.dir, "show", "a~1:f.txt"); !strings.Contains(got, "\nFIVE\n") || strings.Contains(got, "FIFTEEN") { + t.Errorf("a's first commit: %q", got) + } + if got := gittest.Run(t, f.dir, "show", "a:f.txt"); !strings.Contains(got, "\nFIVE\n") { + t.Errorf("a's tip lost the change: %q", got) + } + if got := gittest.Run(t, f.dir, "show", "b:f.txt"); !strings.Contains(got, "\nFIVE\n") || !strings.Contains(got, "\nFIFTEEN\n") || strings.Contains(got, "\none\n") { + t.Errorf("b: %q", got) + } + if got := gittest.Run(t, f.dir, "show", "c:f.txt"); !strings.Contains(got, "\nFIFTEEN\n") { + t.Errorf("c was not restacked onto the new b: %q", got) + } + if n := gittest.Run(t, f.dir, "rev-list", "--count", "main..c"); n != "4" { + t.Errorf("history grew or shrank: %s commits", n) + } + // Messages, authors and dates survive; the old tips are gone from the branches. + if subj := gittest.Run(t, f.dir, "log", "--format=%s", "main..b"); subj != "feat: b\nfeat: a file\nfeat: a" { + t.Errorf("subjects = %q", subj) + } + if an := gittest.Run(t, f.dir, "log", "--format=%an <%ae> %at", "main..b"); an != authorsBefore || strings.Count(an, "\n") != 2 { + t.Errorf("authors = %q, were %q", an, authorsBefore) + } + if f.rev(t, "a") == oldA || f.rev(t, "b") == oldB || f.rev(t, "c") == oldC { + t.Error("a branch did not move") + } + // What was not absorbed is exactly what is still staged; the + // unstaged edit is untouched. + if out := gittest.Run(t, f.dir, "diff", "--cached", "--name-only"); out != "f.txt\nnew.txt" { + t.Errorf("still staged: %q", out) + } + staged := gittest.Run(t, f.dir, "diff", "--cached") + if !strings.Contains(staged, "+one") || strings.Contains(staged, "FIVE") || strings.Contains(staged, "FIFTEEN") { + t.Errorf("staged diff = %q", staged) + } + if out := gittest.Run(t, f.dir, "diff", "--name-only"); out != "a.txt" { + t.Errorf("unstaged = %q", out) + } + // Metadata: heads and bases follow. + br := f.fb.graph.Stacks[0].Branches + if br[0].Head != f.rev(t, "a") || br[1].Head != f.rev(t, "b") || br[1].Base != f.rev(t, "a") || br[2].Head != f.rev(t, "c") || br[2].Base != f.rev(t, "b") { + t.Errorf("metadata = %+v", br) + } + if br[0].Base != f.rev(t, "main") { + t.Errorf("a's base should not change: %+v", br[0]) + } +} + +func TestAbsorbIntoCurrentBranchOnly(t *testing.T) { + f := newAbsorbFixture(t) + oldA := f.rev(t, "a") + f.stageLine(t, 15, "FIFTEEN") + res, err := f.app.Absorb(context.Background(), f.repo, app.AbsorbOptions{}) + if err != nil || len(res.Rewritten) != 1 || res.Rewritten[0].Name != "b" || f.rev(t, "a") != oldA { + t.Errorf("res = %+v %v", res, err) + } + if _, err := gittest.RunErr(t, f.dir, "diff", "--cached", "--quiet"); err != nil { + t.Error("index should be clean") + } +} + +func TestAbsorbTwoHunksIntoFileCreatedInStack(t *testing.T) { + f := newAbsorbFixture(t) + // a created g.txt (20 lines); edits to lines 3 and 10 both go to a. + gittest.Run(t, f.dir, "switch", "-q", "a") + gittest.Commit(t, f.dir, "g.txt", numbered(), "feat: g") + gittest.Run(t, f.dir, "switch", "-q", "b") + gittest.Run(t, f.dir, "rebase", "-q", "a") + gittest.Run(t, f.dir, "switch", "-q", "c") + gittest.Run(t, f.dir, "rebase", "-q", "b") + gittest.Run(t, f.dir, "switch", "-q", "b") + br := f.fb.graph.Stacks[0].Branches + br[0].Head, br[1].Head, br[1].Base, br[2].Head, br[2].Base = f.rev(t, "a"), f.rev(t, "b"), f.rev(t, "a"), f.rev(t, "c"), f.rev(t, "b") + + want := withLine(withLine(numbered(), 10, "ten"), 3, "three\nthree and a half") // line 10 first: the insertion shifts what follows + gittest.WriteFile(t, f.dir, "g.txt", want) + gittest.Run(t, f.dir, "add", "g.txt") + res, err := f.app.Absorb(context.Background(), f.repo, app.AbsorbOptions{}) + if err != nil || res.Plan.Absorbed != 2 || res.Plan.Commits != 1 { + t.Fatalf("res = %+v %v", res, err) + } + if got := gittest.Run(t, f.dir, "show", "a:g.txt") + "\n"; got != want { + t.Errorf("a:g.txt = %q, want %q", got, want) + } + if _, err := gittest.RunErr(t, f.dir, "diff", "--cached", "--quiet"); err != nil { + t.Error("index should be clean") + } +} + +func TestAbsorbFileWithoutTrailingNewline(t *testing.T) { + f := newAbsorbFixture(t) + gittest.Run(t, f.dir, "switch", "-q", "a") + gittest.Commit(t, f.dir, "n.txt", "x\ny", "feat: no newline") + gittest.Run(t, f.dir, "switch", "-q", "b") + gittest.Run(t, f.dir, "rebase", "-q", "a") + gittest.Run(t, f.dir, "switch", "-q", "c") + gittest.Run(t, f.dir, "rebase", "-q", "b") + gittest.Run(t, f.dir, "switch", "-q", "b") + br := f.fb.graph.Stacks[0].Branches + br[0].Head, br[1].Head, br[1].Base, br[2].Head, br[2].Base = f.rev(t, "a"), f.rev(t, "b"), f.rev(t, "a"), f.rev(t, "c"), f.rev(t, "b") + + gittest.WriteFile(t, f.dir, "n.txt", "x\nY") + gittest.Run(t, f.dir, "add", "n.txt") + res, err := f.app.Absorb(context.Background(), f.repo, app.AbsorbOptions{}) + if err != nil || res.Plan.Absorbed != 1 { + t.Fatalf("res = %+v %v", res, err) + } + if got := gittest.Run(t, f.dir, "show", "a:n.txt"); got != "x\nY" { + t.Errorf("a:n.txt = %q", got) + } + if _, err := gittest.RunErr(t, f.dir, "diff", "--cached", "--quiet"); err != nil { + t.Error("index should be clean") + } +} + +func TestAbsorbOtherWorktree(t *testing.T) { + f := newAbsorbFixture(t) + ctx := context.Background() + wt := t.TempDir() + gittest.Run(t, f.dir, "worktree", "add", "-q", wt, "a") + + // Dirty there: refused before anything moves. + gittest.WriteFile(t, wt, "a.txt", "dirty\n") + oldA := f.rev(t, "a") + f.stageLine(t, 5, "FIVE") + _, err := f.app.Absorb(ctx, f.repo, app.AbsorbOptions{}) + if !errors.Is(err, &stack.Error{Kind: stack.KindInvalidArgs}) || !strings.Contains(err.Error(), "checked out in") { + t.Errorf("dirty worktree: %v", err) + } + if f.rev(t, "a") != oldA { + t.Error("a moved despite the refusal") + } + + // Clean there, apart from an untracked file: moved and the checkout + // brought along. + gittest.Run(t, wt, "checkout", "-q", "--", "a.txt") + gittest.WriteFile(t, wt, "junk.txt", "x\n") + res, err := f.app.Absorb(ctx, f.repo, app.AbsorbOptions{}) + if err != nil || !res.Applied { + t.Fatalf("clean worktree: %+v %v", res, err) + } + if head := gittest.Run(t, wt, "rev-parse", "HEAD"); head != f.rev(t, "a") || head == oldA { + t.Errorf("worktree HEAD = %s, a = %s", head, f.rev(t, "a")) + } + if got := gittest.Run(t, wt, "show", "HEAD:f.txt"); !strings.Contains(got, "\nFIVE\n") || gittest.Run(t, wt, "status", "--porcelain", "--untracked-files=no") != "" { + t.Errorf("worktree not updated cleanly: %q", got) + } + if _, err := os.Stat(filepath.Join(wt, "junk.txt")); err != nil { + t.Errorf("the untracked file should still be there: %v", err) + } + + // A dirty checkout of a branch absorb doesn't rewrite is no reason to refuse. + t.Run("unaffected branch", func(t *testing.T) { + f := newAbsorbFixture(t) + wt := t.TempDir() + gittest.Run(t, f.dir, "worktree", "add", "-q", wt, "c") + gittest.WriteFile(t, wt, "c.txt", "dirty\n") + oldC := f.rev(t, "c") + f.stageLine(t, 15, "FIFTEEN") + res, err := f.app.Absorb(ctx, f.repo, app.AbsorbOptions{}) + if err != nil { + t.Fatalf("absorb: %v", err) + } + if !res.Applied || len(res.Rewritten) != 1 || res.Rewritten[0].Name != "b" { + t.Errorf("res = %+v", res) + } + if f.rev(t, "c") != oldC || slices.Contains(res.Restacked, "c") { + t.Errorf("c should have been skipped: %+v", res) + } + if len(res.Notices) == 0 { + t.Error("the skipped restack should have been noticed") + } + if got := gittest.Run(t, wt, "status", "--porcelain"); got != "M c.txt" { + t.Errorf("c's checkout was touched: %q", got) + } + }) +} + +func TestAbsorbRefusesWhenABranchMovedMeanwhile(t *testing.T) { + f := newAbsorbFixture(t) + f.stageLine(t, 5, "FIVE") + // A Preview hook is the one place something can happen between the + // plan and the ref transaction. + oldA := f.rev(t, "a") + moved := "" + _, err := f.app.Absorb(context.Background(), f.repo, app.AbsorbOptions{Preview: func(*app.AbsorbPlan) { + gittest.Run(t, f.dir, "update-ref", "refs/heads/a", "main") + moved = f.rev(t, "a") + }}) + // (a now sits at main, which no candidate commit is; the planner ran + // against the tips it read before the hook, and the transaction must + // notice the difference.) + if !errors.Is(err, &stack.Error{Kind: stack.KindInvalidArgs}) || !strings.Contains(err.Error(), "moved while") { + t.Errorf("err = %v", err) + } + if f.rev(t, "a") != moved || moved == oldA { + t.Error("the transaction should have left the moved ref alone") + } +} + +func TestAbsorbConflictAboveLeavesTheAbsorbInPlace(t *testing.T) { + f := newAbsorbFixture(t) + ctx := context.Background() + // c also changes line 15, so restacking it onto the rewritten b conflicts. + gittest.Run(t, f.dir, "switch", "-q", "c") + gittest.Commit(t, f.dir, "f.txt", withLine(gittest.Run(t, f.dir, "show", "HEAD:f.txt")+"\n", 15, "c's fifteen"), "feat: c15") + f.fb.graph.Stacks[0].Branches[2].Head = f.rev(t, "c") + gittest.Run(t, f.dir, "switch", "-q", "b") + oldB, oldC := f.rev(t, "b"), f.rev(t, "c") + + f.stageLine(t, 15, "FIFTEEN") + res, err := f.app.Absorb(ctx, f.repo, app.AbsorbOptions{}) + if !errors.Is(err, &stack.Error{Kind: stack.KindConflict}) { + t.Fatalf("want a conflict, got %v", err) + } + if !res.Applied || len(res.Rewritten) != 1 || res.Rewritten[0].Name != "b" || f.rev(t, "b") == oldB { + t.Errorf("absorb should have happened: %+v", res) + } + if len(res.Restacked) != 0 { + t.Errorf("nothing was restacked yet: %v", res.Restacked) + } + if got := gittest.Run(t, f.dir, "show", "b:f.txt"); !strings.Contains(got, "\nFIFTEEN\n") { + t.Errorf("b = %q", got) + } + // The usual abort puts c back and the user back on b. + ar, err := f.app.Restack(ctx, f.repo, app.RestackOptions{Abort: true}) + if err != nil || gittest.Run(t, f.dir, "branch", "--show-current") != "b" { + t.Errorf("abort: %+v %v", ar, err) + } + if f.rev(t, "b") == oldB { + t.Error("abort must not undo the absorb itself") + } + if f.rev(t, "c") != oldC { + t.Errorf("c should be back at its tip from before the absorb: %s, was %s", f.rev(t, "c"), oldC) + } +} + +func TestAbsorbRestackRefusalAboveSaysWhatIsLeft(t *testing.T) { + f := newAbsorbFixture(t) + ctx := context.Background() + gittest.Run(t, f.dir, "switch", "-q", "c") + gittest.Commit(t, f.dir, "f.txt", withLine(gittest.Run(t, f.dir, "show", "HEAD:f.txt")+"\n", 15, "c's fifteen"), "feat: c15") + f.fb.graph.Stacks[0].Branches[2].Head = f.rev(t, "c") + gittest.Run(t, f.dir, "switch", "-q", "b") + oldB, oldC := f.rev(t, "b"), f.rev(t, "c") + + gittest.WriteFile(t, f.dir, "a.txt", "unstaged\n") + f.stageLine(t, 15, "FIFTEEN") + f.stageLine(t, 1, "one") + res, err := f.app.Absorb(ctx, f.repo, app.AbsorbOptions{}) + if !errors.Is(err, &stack.Error{Kind: stack.KindInvalidArgs}) { + t.Fatalf("want invalid_args, got %v", err) + } + if !res.Applied || len(res.Rewritten) != 1 || res.Rewritten[0].Name != "b" || f.rev(t, "b") == oldB { + t.Errorf("absorb should have happened: %+v", res) + } + if !strings.Contains(err.Error(), "absorbed, but c above b was not restacked") { + t.Errorf("message = %q", err) + } + se, _ := errors.AsType[*stack.Error](err) + var restackStep, tryAgain bool + for _, step := range se.NextSteps { + restackStep = restackStep || strings.Contains(step, "git stack restack") + tryAgain = tryAgain || strings.Contains(step, "try again") + } + if !restackStep || tryAgain { + t.Errorf("steps = %v", se.NextSteps) + } + if _, err := os.Stat(filepath.Join(f.repo.GitDir, "git-stack", "restack.json")); !errors.Is(err, os.ErrNotExist) { + t.Errorf("no restack state should exist: %v", err) + } + if f.rev(t, "c") != oldC { + t.Error("c should be untouched") + } +} diff --git a/pkg/app/app.go b/pkg/app/app.go index 5800058..848d3f2 100644 --- a/pkg/app/app.go +++ b/pkg/app/app.go @@ -49,6 +49,8 @@ const ( PhaseUpdate Phase = "update" PhaseInstall Phase = "install" PhaseMerge Phase = "merge" + // PhaseAbsorb: rewriting the stack's commits with the staged changes. + PhaseAbsorb Phase = "absorb" // PhaseRefresh: fetching pull request state from the forge for a command // that only reads it (log, submit's look at which PRs exist). PhaseRefresh Phase = "refresh" diff --git a/pkg/app/restack.go b/pkg/app/restack.go index abfb592..867ff69 100644 --- a/pkg/app/restack.go +++ b/pkg/app/restack.go @@ -368,14 +368,20 @@ func (a *App) applyMoves(ctx context.Context, run *restackRun, p *restackPlan, r return err } } - return a.d.Meta.Update(ctx, run.repo, func(g *stack.Graph) error { + return a.updateHeadsAndBases(ctx, run.repo, heads, p.bases) +} + +// updateHeadsAndBases records the new head of every branch in heads and +// the new base of every branch in bases. +func (a *App) updateHeadsAndBases(ctx context.Context, repo git.Repo, heads, bases map[string]string) error { + return a.d.Meta.Update(ctx, repo, func(g *stack.Graph) error { for i := range g.Stacks { for j := range g.Stacks[i].Branches { b := &g.Stacks[i].Branches[j] if to, ok := heads[b.Name]; ok { b.Head = to } - if base, ok := p.bases[b.Name]; ok { + if base, ok := bases[b.Name]; ok { b.Base = base } } diff --git a/pkg/git/patch.go b/pkg/git/patch.go new file mode 100644 index 0000000..ca10dc1 --- /dev/null +++ b/pkg/git/patch.go @@ -0,0 +1,111 @@ +package git + +import ( + "context" + "errors" + "os" + "regexp" + "strings" + + "github.com/DomBlack/git-stack/pkg/exec" +) + +// zeroContext is how absorb reads a diff: no context lines, renames +// detected, a/ and b/ prefixes whatever the user's diff settings say, no +// external diff or textconv (the patch has to apply to the real blob), and +// submodules as one commit line rather than a nested diff. +var zeroContext = []string{"-U0", "--find-renames", "--no-color", "--no-ext-diff", "--no-textconv", "--submodule=short", "--src-prefix=a/", "--dst-prefix=b/"} + +// DiffCommit returns rev's changes against its first parent as a zero +// context diff with renames detected. +func (c *Client) DiffCommit(ctx context.Context, repo Repo, rev string) (string, error) { + args := append([]string{"diff"}, zeroContext...) + res, err := c.gitIn(ctx, repo, append(args, rev+"^", rev, "--")...) + if err != nil { + return "", err + } + return string(res.Stdout), nil +} + +// StagedPatch returns the index against HEAD the way DiffCommit reads a +// commit. +func (c *Client) StagedPatch(ctx context.Context, repo Repo) (string, error) { + args := append([]string{"diff", "--cached"}, zeroContext...) + res, err := c.gitIn(ctx, repo, append(args, "--")...) + if err != nil { + return "", err + } + return string(res.Stdout), nil +} + +// HasMerges reports whether from..to holds a merge commit. +func (c *Client) HasMerges(ctx context.Context, repo Repo, from, to string) (bool, error) { + res, err := c.gitIn(ctx, repo, "rev-list", "--merges", "-n", "1", from+".."+to, "--") + if err != nil { + return false, err + } + return res.Out() != "", nil +} + +// PatchError is a patch git apply would not put on the tree as given: the +// lines it removes are not where it says, it applied them at an offset, or +// git rejected it for any other reason. Detail is git's output. +type PatchError struct { + Detail string +} + +func (e *PatchError) Error() string { return "patch does not apply: " + lastLine(e.Detail) } + +// PatchTree applies patch, a zero context diff, to tree and returns the new +// tree. It goes through a temporary index in the git dir, so the real +// index and working tree are never touched, and the temporary index is +// removed whether or not it worked. git checks every removed line against +// the file (line numbers are only a hint to it), so a patch that doesn't +// fit comes back as a *PatchError and nothing is written. git also applies +// a hunk at an offset when it finds the removed lines elsewhere; a correct +// plan always matches at the stated line, so an offset is refused too. +// Whitespace settings are ignored so the applied lines are exactly the +// patch's. +func (c *Client) PatchTree(ctx context.Context, repo Repo, tree, patch string) (string, error) { + f, err := os.CreateTemp(repo.GitDir, "git-stack-absorb-*.index") + if err != nil { + return "", err + } + path := f.Name() + _ = f.Close() + // read-tree wants to create the index itself; it only needs the name. + _ = os.Remove(path) + defer func() { _ = os.Remove(path) }() + env := []string{"GIT_INDEX_FILE=" + path} + if _, err := c.gitInput(ctx, repo, nil, env, "read-tree", tree); err != nil { + return "", gitFailure("read-tree", err) + } + applied, err := c.gitInput(ctx, repo, strings.NewReader(patch), env, "apply", "-v", "--cached", "--unidiff-zero", "--whitespace=nowarn", "-") + if err != nil { + if ee, ok := errors.AsType[*exec.ExitError](err); ok { + return "", &PatchError{Detail: strings.TrimSpace(ee.Result.Err())} + } + return "", err + } + // -v makes git say "Hunk #1 succeeded at 1 (offset -2 lines)". + if offsetApplied.MatchString(applied.Err()) { + return "", &PatchError{Detail: strings.TrimSpace(applied.Err())} + } + res, err := c.gitInput(ctx, repo, nil, env, "write-tree") + if err != nil { + return "", gitFailure("write-tree", err) + } + return res.Out(), nil +} + +// offsetApplied matches git's "Hunk #1 succeeded at 3 (offset 1 line)". The +// verbose output also names every file, so "offset" alone proves nothing. +var offsetApplied = regexp.MustCompile(`succeeded at \d+ \(offset `) + +// gitFailure turns a non-zero exit into an error carrying git's message. +func gitFailure(what string, err error) error { + if ee, ok := errors.AsType[*exec.ExitError](err); ok { + return errors.New(what + ": " + strings.TrimSpace(ee.Result.Err())) + } + return err +} diff --git a/pkg/git/patch_test.go b/pkg/git/patch_test.go new file mode 100644 index 0000000..978a430 --- /dev/null +++ b/pkg/git/patch_test.go @@ -0,0 +1,185 @@ +package git_test + +import ( + "context" + "errors" + "path/filepath" + "strings" + "testing" + + "github.com/DomBlack/git-stack/pkg/exec" + "github.com/DomBlack/git-stack/pkg/git" + "github.com/DomBlack/git-stack/pkg/git/gittest" +) + +func patchFixture(t *testing.T) (*git.Client, git.Repo, string) { + t.Helper() + gittest.Isolate(t) + dir := gittest.InitRepo(t) + gittest.Commit(t, dir, "f.txt", "l1\nl2\nl3\nl4\nl5\n", "five lines") + c := git.New(exec.New()) + repo, err := c.Discover(context.Background(), dir) + if err != nil { + t.Fatal(err) + } + return c, repo, dir +} + +func TestDiffCommitAndStagedPatchAreZeroContextWithRenames(t *testing.T) { + c, repo, dir := patchFixture(t) + ctx := context.Background() + // A user's diff settings must not leak into what we parse. + gittest.Run(t, dir, "config", "diff.noprefix", "true") + gittest.Run(t, dir, "config", "diff.context", "3") + gittest.Run(t, dir, "mv", "f.txt", "g.txt") + gittest.WriteFile(t, dir, "g.txt", "l1\nL2\nl3\nl4\nl5\n") + gittest.Run(t, dir, "add", "g.txt") + staged, err := c.StagedPatch(ctx, repo) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{"rename from f.txt", "rename to g.txt", "--- a/f.txt", "+++ b/g.txt", "@@ -2 +2 @@", "-l2", "+L2"} { + if !strings.Contains(staged, want) { + t.Errorf("staged patch lacks %q:\n%s", want, staged) + } + } + // Git puts a function heading after the @@ (here " l1"), so look for + // context lines by their leading space rather than for their text. + for line := range strings.SplitSeq(staged, "\n") { + if strings.HasPrefix(line, " ") { + t.Errorf("staged patch has a context line %q:\n%s", line, staged) + } + } + gittest.Run(t, dir, "commit", "-q", "-m", "rename and edit") + diff, err := c.DiffCommit(ctx, repo, "HEAD") + if err != nil || !strings.Contains(diff, "rename from f.txt") || !strings.Contains(diff, "+L2") { + t.Errorf("DiffCommit = %q, %v", diff, err) + } +} + +func TestHasMerges(t *testing.T) { + c, repo, dir := patchFixture(t) + ctx := context.Background() + base := gittest.Run(t, dir, "rev-parse", "HEAD") + gittest.Run(t, dir, "switch", "-q", "-c", "side") + gittest.Commit(t, dir, "s.txt", "s", "side") + gittest.Run(t, dir, "switch", "-q", "main") + gittest.Commit(t, dir, "m.txt", "m", "main") + if has, err := c.HasMerges(ctx, repo, base, "HEAD"); has || err != nil { + t.Errorf("linear: %v %v", has, err) + } + gittest.Run(t, dir, "merge", "-q", "--no-ff", "-m", "merge side", "side") + if has, err := c.HasMerges(ctx, repo, base, "HEAD"); !has || err != nil { + t.Errorf("with a merge: %v %v", has, err) + } +} + +const editL2 = "diff --git a/f.txt b/f.txt\n--- a/f.txt\n+++ b/f.txt\n@@ -2,1 +2,1 @@\n-l2\n+L2\n" + +func TestPatchTree(t *testing.T) { + c, repo, dir := patchFixture(t) + ctx := context.Background() + tree := gittest.Run(t, dir, "rev-parse", "HEAD^{tree}") + treeBefore := tree // the real index's write-tree must still equal it below + nt, err := c.PatchTree(ctx, repo, tree, editL2) + if err != nil { + t.Fatal(err) + } + if got := gittest.Run(t, dir, "cat-file", "-p", nt+":f.txt"); got != "l1\nL2\nl3\nl4\nl5" { + t.Errorf("patched content = %q", got) + } + // The real index and working tree are untouched, and the temporary + // index is gone. + if gittest.Run(t, dir, "write-tree") != treeBefore || gittest.Run(t, dir, "status", "--porcelain") != "" { + t.Error("the real index or working tree changed") + } + if left, _ := filepath.Glob(filepath.Join(repo.GitDir, "git-stack-absorb-*")); len(left) != 0 { + t.Errorf("temporary index left behind: %v", left) + } +} + +func TestPatchTreeRemovesTempIndexWhenReadTreeFails(t *testing.T) { + c, repo, _ := patchFixture(t) + _, err := c.PatchTree(context.Background(), repo, strings.Repeat("0", 40), editL2) + if err == nil { + t.Fatal("a bogus tree id should fail") + } + if _, ok := errors.AsType[*git.PatchError](err); ok { + t.Errorf("read-tree failing is not a patch error: %v", err) + } + if left, _ := filepath.Glob(filepath.Join(repo.GitDir, "git-stack-absorb-*")); len(left) != 0 { + t.Errorf("temporary index left behind: %v", left) + } +} + +func TestPatchTreeRejectsAPatchThatDoesNotFit(t *testing.T) { + c, repo, dir := patchFixture(t) + tree := gittest.Run(t, dir, "rev-parse", "HEAD^{tree}") + bad := "diff --git a/f.txt b/f.txt\n--- a/f.txt\n+++ b/f.txt\n@@ -2,1 +2,1 @@\n-nope\n+L2\n" + _, err := c.PatchTree(context.Background(), repo, tree, bad) + pe, ok := errors.AsType[*git.PatchError](err) + if !ok || !strings.Contains(pe.Detail, "patch does not apply") { + t.Errorf("err = %v", err) + } + if left, _ := filepath.Glob(filepath.Join(repo.GitDir, "git-stack-absorb-*")); len(left) != 0 { + t.Errorf("temporary index left behind: %v", left) + } +} + +func TestPatchTreeRefusesAnOffsetApply(t *testing.T) { + c, repo, dir := patchFixture(t) + tree := gittest.Run(t, dir, "rev-parse", "HEAD^{tree}") + // The header says line 4 but l2 is on line 2: git would apply it at an + // offset. + moved := "diff --git a/f.txt b/f.txt\n--- a/f.txt\n+++ b/f.txt\n@@ -4,1 +4,1 @@\n-l2\n+L2\n" + _, err := c.PatchTree(context.Background(), repo, tree, moved) + pe, ok := errors.AsType[*git.PatchError](err) + if !ok || !strings.Contains(pe.Detail, "offset") { + t.Errorf("err = %v", err) + } + if left, _ := filepath.Glob(filepath.Join(repo.GitDir, "git-stack-absorb-*")); len(left) != 0 { + t.Errorf("temporary index left behind: %v", left) + } +} + +func TestPatchTreeAcceptsACorrectHunkInAFileNamedOffset(t *testing.T) { + c, repo, dir := patchFixture(t) + gittest.Commit(t, dir, "offset.txt", "a\nb\n", "offset file") + tree := gittest.Run(t, dir, "rev-parse", "HEAD^{tree}") + patch := "diff --git a/offset.txt b/offset.txt\n--- a/offset.txt\n+++ b/offset.txt\n@@ -2,1 +2,1 @@\n-b\n+B\n" + nt, err := c.PatchTree(context.Background(), repo, tree, patch) + if err != nil { + t.Fatal(err) + } + if got := gittest.Run(t, dir, "cat-file", "-p", nt+":offset.txt"); got != "a\nB" { + t.Errorf("patched content = %q", got) + } +} + +func TestPatchTreeIgnoresWhitespaceConfig(t *testing.T) { + c, repo, dir := patchFixture(t) + gittest.Run(t, dir, "config", "apply.whitespace", "fix") + tree := gittest.Run(t, dir, "rev-parse", "HEAD^{tree}") + trailing := "diff --git a/f.txt b/f.txt\n--- a/f.txt\n+++ b/f.txt\n@@ -2,1 +2,1 @@\n-l2\n+L2 \n" + nt, err := c.PatchTree(context.Background(), repo, tree, trailing) + if err != nil { + t.Fatal(err) + } + if got := gittest.Run(t, dir, "cat-file", "-p", nt+":f.txt"); !strings.Contains(got, "L2 \n") { + t.Errorf("trailing space was 'fixed': %q", got) + } +} + +func TestPatchTreeOddPath(t *testing.T) { + c, repo, dir := patchFixture(t) + gittest.Commit(t, dir, "sp ace/café.txt", "a\nb\n", "odd path") + tree := gittest.Run(t, dir, "rev-parse", "HEAD^{tree}") + patch := "diff --git \"a/sp ace/caf\\303\\251.txt\" \"b/sp ace/caf\\303\\251.txt\"\n--- \"a/sp ace/caf\\303\\251.txt\"\n+++ \"b/sp ace/caf\\303\\251.txt\"\n@@ -2,1 +2,1 @@\n-b\n+B\n" + nt, err := c.PatchTree(context.Background(), repo, tree, patch) + if err != nil { + t.Fatal(err) + } + if got := gittest.Run(t, dir, "cat-file", "-p", nt+":sp ace/café.txt"); got != "a\nB" { + t.Errorf("content = %q", got) + } +}