gitq absorb
Distributes uncommitted worktree changes across the stack branches that own the lines each edit is on, then restacks below the branches it touched.
Usage
gitq absorb [--stack <name>] [--at <branch>[:<glob>]]... [--preview]
Flags
| flag | meaning |
|---|---|
--stack <name> | Which tracked stack to absorb into. Optional when the repo has exactly one; required otherwise. See Global flags. |
--preview | Show the file attribution without committing or restacking anything. |
--at <branch> | Commit every attributed file to <branch> instead of where attribution would put it. Refused before anything is read if <branch> is not in the stack. See Overriding attribution below. |
--at <branch>:<glob> | Commit only the files matching <glob> to <branch>, leaving everything else to attribution. Repeatable. See Overriding only part of the change. |
Behavior
- Attribution. For every changed file (modified, staged, or untracked), absorb asks which branch owns the lines the edit is on. It blames the changed line ranges of the file at HEAD, with
-U0so a hunk covers only the lines that moved and not three lines of innocent context, and takes the deepest stack branch owning any of them (blameOwners,src/core/absorb.ts:112-133). Only when blame has no opinion does it fall back to the older question, "whose commits touched this path at all", answered by the same leaves-to-root walk (src/core/absorb.ts:156-200). Blame has no opinion about a new file, a binary file, lines written outside the stack, or a repo where blame will not run, so those keep the file-level answer exactly as before. An edit spanning several owners goes to the deepest of them: a file is committed to one branch, and the deepest owner is the only choice that always replays. A file no branch owns either way is not committed anywhere: it comes back underunattributedand absorb leaves it in the worktree, dirty, for you to place yourself. It is snapshotted before the stash like everything else and written back after the restack (src/core/absorb.ts:314-336), so it survives the round trip through the stash the attributed files take. - The snapshot is of the worktree entry, not the bytes (
src/core/absorb.ts:193-226): type, mode, symlink target, and what the index held for the path. So a 755 script comes back 755, a symlink comes back a symlink (dangling target included), and a partially staged file keeps its staged blob in the index and its full edit in the worktree. A changed file absorb cannot read is not guessed at: unless git itself called that path a deletion, the run aborts before anything is stashed, naming the files, with nothing committed or removed. --previewonly runs the attribution (AbsorbEngine.previewAbsorb) and prints it; it mutates nothing, takes no lease, and writes no operation-log entry (src/cli/commands/surgery.ts:48-58). It is the one mutation-family flag allowed to run while the stack is parked.- Without
--preview, guarded by the stack lease. Before doing anything, it runs the same preview and refuses if any branch it would attribute a file to is checked out in a different, non-work-slot worktree, whether or not that worktree is dirty:branch "<name>" is checked out in slot "<slot>" (<path>); run this from that worktree or free the branch first(src/cli/commands/surgery.ts:60-71). - Commit phase runs in the worktree you invoked it from, not the leased slot, because it needs a real tree to write files into: stash everything, then for each owning branch in turn, check it out, write its files back,
git add,git commit --amend --no-edit(src/core/absorb.ts:396-436). This is an amend of the branch's tip commit, not a new commit. The stash it takes is dropped only once the unattributed files are back on disk, so that work is never held in memory alone. - The commit phase is not atomic. If a later branch's amend fails (a
pre-commithook is the realistic case, since this worktree's hooks stay live, unlike a work slot's), absorb stops right there without rolling back branches it already amended. It reportsnothing absorbedand exits1, which understates what happened; checkgit logfor the branches you expected to change. That path attempts to check your branch back out and pop the stash, which normally brings the whole dirty tree back. When either step fails — the hook that refused the amend usually refuses the checkout too — the message names the branch you are actually on and the retainedstash@{0}, with the two commands to recover it, and absorb will not pop your dirty tree onto a branch you did not pick. - Restack phase. Cascades the descendants of every amended branch onto their parents' new heads, detached in a leased work slot. This is absorb's own loop, not the shared cascade
sync/reparentuse, and it never fetches. - No pause protocol. If the restack conflicts, absorb aborts the in-progress rebase (in the slot, and defensively in your worktree too) and returns a hard failure instead of pausing, keeping the commits it already made:
absorb restack conflicted on <branch>; aborted the rebase (branch edits kept). run gitq sync to restack with full conflict handling(src/cli/commands/surgery.ts:105-111). - Does not override the operation-log predicate (
src/cli/commands/surgery.ts:92-93): the defaultshouldLogonly logs a clean exit0. A restack-conflict exit1writes no entry at all, even though the commit phase already landed real commits.gitq undoafterward has no entry for this absorb; it silently rewinds whichever operation ran before it. - With nothing dirty to absorb, prints
nothing absorbed (no-changes)and exits0. When the tree is dirty but no branch owns any of it, printsnothing absorbed (nothing-attributable)and exits0without stashing, checking out, or committing anything. Both answers come straight off the preview (src/cli/commands/surgery.ts:76-86), before a lease is taken: a run with nothing to commit takes no lease, materializes no work slot, and writes no operation-log entry. - Recorded to the operation log under
absorb, and reversible: it is one of the four types inREVERSIBLE_OPERATIONS(src/core/undo.ts:16-21), but only for a run that actually reached exit0.
Overriding attribution with --at
Attribution answers "which branch owns these lines". That is not always "which branch should carry the fix": with one MR and one pipeline per branch, a fix committed above the branch that introduced the problem leaves every branch below it red on the very thing being fixed. --at <branch> sends every attributed file to <branch> instead.
The target is checked against the stack before absorb reads the worktree, since a branch the commit walk never visits would silently swallow the files: --at "<branch>" is not in stack "<name>" (have: ...).
Overriding only part of the change
A bare --at is all or nothing: every file in the change goes to one branch. That is the wrong instrument when attribution got most of the change right and one file wrong, which is the common case on a deep stack. --at <branch>:<glob> claims only the files its glob matches; everything else still attributes normally.
The flag is repeatable, and the two forms compose — a bare --at alongside scoped ones is the catch-all for whatever no glob claimed:
# manifest.json to the branch that can actually carry it; the rest as computed
gitq absorb --at s1:'packages/**/package.json'
# everything to s2, except the package files, which go to s1
gitq absorb --at s1:'packages/**' --at s2
Globs are picomatch patterns matched against repo-relative paths, the same syntax gitq split --files takes. Quote them, or the shell expands them first. The split on the first : is unambiguous because git refs cannot contain a colon.
Three things are refused rather than guessed at, all before anything is read or written:
- One file claimed by two branches. Overlapping globs are fine while they agree; a file matched by patterns naming different branches is refused, naming the file and both patterns (
resolveScopedTargets,src/core/absorb.ts:90-123). Resolving it by declaration order would put a fix on a branch you did not choose, which is the failure--atexists to prevent. - A glob that matches nothing. A typo looks exactly like a working override whose files quietly went to attribution's choice instead, so an unused pattern is an error, not a no-op.
- More than one bare
--at. Two catch-alls would each claim "the rest" (parseAtTargets,src/cli/commands/surgery.ts:76-112).
Run it under --preview first: the preview resolves the overrides exactly as the real run will, so it fails on the same three, and its attributed map is the final answer for where each file would land.
The edit is replayed, not the file. Absorb normally writes the working tree's copy of a file wholesale, which is correct only when the target holds the version you edited against ... what attribution guarantees and --at does not. Writing the whole file onto an ancestor would carry every descendant's changes to that file down with it, moving their commits into that branch's MR. So when the target's copy differs from the base, absorb three-way merges your edit onto the target's copy instead (resolveContentForBranch, src/core/absorb.ts:226-265).
A merge that conflicts means the edit genuinely has no place on that branch. That file drops out of the attribution before the stash, so nothing is committed, nothing is stashed, and no rebase starts. It is reported under unapplied and named on stdout:
$ gitq absorb --at s1
nothing absorbed (nothing-attributable)
test/api.spec.ts: does not replay onto s1, left in the worktree
unapplied is a subset of unattributed: both are left dirty, but the reasons differ and so do the remedies. An unattributed file has no owner, and you place it yourself. An unapplied file has an owner you overruled, and the fix is a different --at target, scoping the override with a glob so only the part that belongs there moves, or splitting the edit.
Exit stays 0: nothing was refused and nothing failed, there was simply nothing absorb could safely commit. Check unapplied under --json rather than the exit code.
Exit codes
0: absorbed (or nothing to absorb), or--previewcompleted.1: two different shapes. A hard refusal (lease held,--stackunresolved, an attributed branch checked out elsewhere) or a restack conflict (aborted, pointing atgitq sync) both printgitq: <message>with nothing on stdout. A completed run with a failed attribution is the other shape, the per-item onesyncandreparentalso use: checkresult.attributions[].successunder--json. A run that finished but left work for you to fetch by hand takes that second shape too:result.recoverycarries the line (the retainedstash@{0}, the branch it could not leave), it is printed under the headline, and the exit is1even when the commits landed. See Exit codes for both shapes side by side.- Never exits
2: absorb has no pause protocol of its own.
See also
- Absorb uncommitted changes: a full walkthrough, including the restack-conflict recovery path, with real captures.
gitq sync: what to run after a restack conflict.gitq undo: what it can and cannot give back after absorb.