Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .changes/unreleased/Added-20260602-161111.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
kind: Added
body: 'submodule: Add primitives for transactional checkout and recursive operations on tracked submodules.'
time: 2026-06-02T16:11:11.782224-04:00
36 changes: 21 additions & 15 deletions doc/includes/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -310,9 +310,11 @@ Before merging, the stack is checked for branches
whose base PR was already merged on the forge.
Use --no-branch-check to skip this validation.

Before each merge, waits for CI checks to pass.
Use --build-timeout to configure the maximum wait
before failing if checks are not ready.
Before each merge, waits for merge readiness:
the forge must observe the pushed head
and report that the CR is ready to merge.
Use --ready-timeout to configure the maximum wait
before failing if merge readiness is not reached.

By default, a branch failure skips that branch's upstack descendants,
but independent sibling branches continue.
Expand All @@ -321,12 +323,12 @@ Use --fail-fast to stop the queue after the first branch failure.
**Flags**

* `--method=METHOD` ([:material-wrench:{ .middle title="spice.merge.method" }](/cli/config.md#spicemergemethod)): Preferred merge method. One of 'merge', 'squash', and 'rebase'.
* `--build-timeout=30m` ([:material-wrench:{ .middle title="spice.merge.buildTimeout" }](/cli/config.md#spicemergebuildtimeout)): Max time to wait for CI checks before each merge. 0 means check once.
* `--ready-timeout=30m` ([:material-wrench:{ .middle title="spice.merge.readyTimeout" }](/cli/config.md#spicemergereadytimeout)): Max time to wait for merge readiness before each merge. 0 means check once.
* `--no-branch-check`: Skip stale base validation before merging.
* `--fail-fast`: Stop the merge queue after the first branch failure.
* `--branch=NAME`: Branch whose stack to merge

**Configuration**: [spice.merge.buildTimeout](/cli/config.md#spicemergebuildtimeout), [spice.merge.method](/cli/config.md#spicemergemethod)
**Configuration**: [spice.merge.method](/cli/config.md#spicemergemethod), [spice.merge.readyTimeout](/cli/config.md#spicemergereadytimeout)

### git-spice stack restack {#gs-stack-restack}

Expand Down Expand Up @@ -624,7 +626,7 @@ This command acts as a local merge queue:
it merges one Change Request,
waits for that merge to finish,
restacks and updates the next Change Request,
waits for its CI checks to pass,
waits for merge readiness on the updated Change Request,
and then repeats the process.

For a stack like this:
Expand All @@ -642,13 +644,15 @@ Before merging, the downstack is checked for branches
whose base PR was already merged on the forge.
Use --no-branch-check to skip this validation.

Before each merge, waits for CI checks to pass.
Use --build-timeout to configure the maximum wait
Before each merge, waits for merge readiness:
the forge must observe the pushed head
and report that the CR is ready to merge.
Use --ready-timeout to configure the maximum wait
(default: 30m, 0 means fail immediately if not ready).

Between merges, the command waits for each merge
to complete, restacks and updates the next PR,
waits for CI checks on the updated PR,
waits for merge readiness on the updated PR,
and syncs merged branch cleanup.

Use --no-wait for single branch merging
Expand All @@ -658,12 +662,12 @@ when you don't want to wait for the merge to propagate.
**Flags**

* `--method=METHOD` ([:material-wrench:{ .middle title="spice.merge.method" }](/cli/config.md#spicemergemethod)): Preferred merge method. One of 'merge', 'squash', and 'rebase'.
* `--build-timeout=30m` ([:material-wrench:{ .middle title="spice.merge.buildTimeout" }](/cli/config.md#spicemergebuildtimeout)): Max time to wait for CI checks before each merge. 0 means check once.
* `--ready-timeout=30m` ([:material-wrench:{ .middle title="spice.merge.readyTimeout" }](/cli/config.md#spicemergereadytimeout)): Max time to wait for merge readiness before each merge. 0 means check once.
* `--no-wait`: Skip polling for a single branch merge to propagate.
* `--no-branch-check`: Skip stale base validation before merging.
* `--branch=NAME`: Branch to start merging from

**Configuration**: [spice.merge.buildTimeout](/cli/config.md#spicemergebuildtimeout), [spice.merge.method](/cli/config.md#spicemergemethod)
**Configuration**: [spice.merge.method](/cli/config.md#spicemergemethod), [spice.merge.readyTimeout](/cli/config.md#spicemergereadytimeout)

### git-spice downstack edit {#gs-downstack-edit}

Expand Down Expand Up @@ -1133,16 +1137,18 @@ Use --branch to merge a different branch.
The branch must be based directly on trunk.
To merge a stacked branch, use 'gs downstack merge'.

Before merging, waits for CI checks to pass.
Use --build-timeout to configure the maximum wait.
Before merging, waits for merge readiness:
the forge must observe the pushed head
and report that the CR is ready to merge.
Use --ready-timeout to configure the maximum wait.

**Flags**

* `--method=METHOD` ([:material-wrench:{ .middle title="spice.merge.method" }](/cli/config.md#spicemergemethod)): Preferred merge method. One of 'merge', 'squash', and 'rebase'.
* `--build-timeout=30m` ([:material-wrench:{ .middle title="spice.merge.buildTimeout" }](/cli/config.md#spicemergebuildtimeout)): Max time to wait for CI checks before each merge. 0 means check once.
* `--ready-timeout=30m` ([:material-wrench:{ .middle title="spice.merge.readyTimeout" }](/cli/config.md#spicemergereadytimeout)): Max time to wait for merge readiness before each merge. 0 means check once.
* `--branch=NAME`: Branch to merge

**Configuration**: [spice.merge.buildTimeout](/cli/config.md#spicemergebuildtimeout), [spice.merge.method](/cli/config.md#spicemergemethod)
**Configuration**: [spice.merge.method](/cli/config.md#spicemergemethod), [spice.merge.readyTimeout](/cli/config.md#spicemergereadytimeout)

### git-spice branch submit {#gs-branch-submit}

Expand Down
172 changes: 172 additions & 0 deletions internal/git/submodule.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package git
import (
"bufio"
"context"
"errors"
"fmt"
"path/filepath"
"strings"
Expand Down Expand Up @@ -134,3 +135,174 @@ func (w *Worktree) UpdateSubmodulePointer(
}
return nil
}

// SubmoduleStatus captures the runtime state of a submodule
// relative to its parent repository.
type SubmoduleStatus struct {
// Path is the relative path from the parent repo root.
Path string

// HeadHash is the current HEAD commit of the submodule.
HeadHash Hash

// GitlinkHash is the commit hash recorded for this submodule
// in the parent's HEAD tree.
GitlinkHash Hash

// Branch is the current branch name of the submodule.
// Empty when [SubmoduleStatus.Detached] is true.
Branch string

// Detached is true when the submodule is in a detached HEAD state.
Detached bool

// HasGsStore is true when the submodule has been initialized
// with `gs repo init`.
HasGsStore bool
}

// SubmoduleStatus reports the runtime state of the submodule
// at the given relative path.
func (w *Worktree) SubmoduleStatus(
ctx context.Context, path string,
) (*SubmoduleStatus, error) {
status := &SubmoduleStatus{Path: path}

branch, err := w.SubmoduleCurrentBranch(ctx, path)
switch {
case errors.Is(err, ErrDetachedHead):
status.Detached = true
case err != nil:
return nil, fmt.Errorf("current branch: %w", err)
default:
status.Branch = branch
}

head, err := w.SubmoduleHead(ctx, path)
if err != nil {
return nil, fmt.Errorf("head: %w", err)
}
status.HeadHash = head

gitlink, err := w.SubmoduleGitlink(ctx, path)
if err != nil {
return nil, fmt.Errorf("gitlink: %w", err)
}
status.GitlinkHash = gitlink

hasStore, err := w.SubmoduleHasGsStore(ctx, path)
if err != nil {
return nil, fmt.Errorf("gs store: %w", err)
}
status.HasGsStore = hasStore

return status, nil
}

// SubmoduleHead reports the HEAD commit hash of the submodule
// at the given relative path.
func (w *Worktree) SubmoduleHead(
ctx context.Context, path string,
) (Hash, error) {
absPath := filepath.Join(w.rootDir, path)
out, err := newGitCmd(ctx, w.log, w.exec,
"rev-parse", "HEAD^{commit}",
).WithDir(absPath).OutputChomp()
if err != nil {
return "", fmt.Errorf("submodule %s: %w", path, err)
}
return Hash(strings.TrimSpace(out)), nil
}

// SubmoduleGitlink reports the gitlink commit hash recorded
// for the submodule at the given relative path
// in the parent's HEAD tree.
func (w *Worktree) SubmoduleGitlink(
ctx context.Context, path string,
) (Hash, error) {
out, err := w.gitCmd(ctx,
"ls-tree", "HEAD", "--", path,
).OutputChomp()
if err != nil {
return "", fmt.Errorf("ls-tree %s: %w", path, err)
}
// Format: <mode> <type> <hash>\t<path>
// e.g. "160000 commit abc123\tlibs/core"
fields := strings.Fields(out)
if len(fields) < 3 {
return "", fmt.Errorf(
"unexpected ls-tree output for %s: %q",
path, out,
)
}
return Hash(fields[2]), nil
}

// SubmoduleHasGsStore reports whether the submodule
// at the given relative path has been initialized with git-spice
// (i.e., the spice data ref exists).
func (w *Worktree) SubmoduleHasGsStore(
ctx context.Context, path string,
) (bool, error) {
absPath := filepath.Join(w.rootDir, path)
err := newGitCmd(ctx, w.log, w.exec,
"rev-parse", "--verify", "--quiet", "refs/spice/data",
).WithDir(absPath).Run()
if err != nil {
// rev-parse --verify exits non-zero when the ref is absent.
return false, nil //nolint:nilerr
}
return true, nil
}

// HeadSnapshot captures the HEAD state of a worktree
// at a point in time, suitable for restoration via [Worktree.RestoreHead].
type HeadSnapshot struct {
// Branch is the name of the branch HEAD was on.
// Empty when [HeadSnapshot.Detached] is true.
Branch string

// Hash is the commit hash HEAD pointed at.
Hash Hash

// Detached is true when HEAD was detached.
Detached bool
}

// SnapshotHead captures the current HEAD state of the worktree.
// It records whether HEAD is attached to a branch or detached,
// and the commit hash at HEAD.
func (w *Worktree) SnapshotHead(ctx context.Context) (*HeadSnapshot, error) {
snap := &HeadSnapshot{}

branch, err := w.CurrentBranch(ctx)
switch {
case errors.Is(err, ErrDetachedHead):
snap.Detached = true
case err != nil:
return nil, fmt.Errorf("current branch: %w", err)
default:
snap.Branch = branch
}

head, err := w.Head(ctx)
if err != nil {
return nil, fmt.Errorf("head: %w", err)
}
snap.Hash = head

return snap, nil
}

// RestoreHead returns the worktree to the state captured by snap.
// If snap was on a branch, the branch is checked out.
// If snap was detached, HEAD is detached at the captured hash.
// Working-tree changes are carried per `git checkout`'s normal semantics.
func (w *Worktree) RestoreHead(
ctx context.Context, snap *HeadSnapshot,
) error {
if snap.Detached {
return w.DetachHead(ctx, snap.Hash.String())
}
return w.CheckoutBranch(ctx, snap.Branch)
}
Loading
Loading