Absorb uncommitted changes
You have been editing across a stack and now the working tree holds changes belonging to three different branches. gitq absorb works out which branch owns each dirty file, folds the change into that branch, and restacks everything below it. One command instead of a stash, four checkouts, four amends, and a manual rebase.
Every block below came from a real run.
What absorb actually does
In order:
- Attribute. For each dirty file, blame the lines the edit actually changes and find the deepest stack branch that owns any of them. That branch owns the file. When blame has no answer (a new file, a binary one, or lines written outside the stack) fall back to the older rule: the deepest branch whose own commits touched that file at all. A file no branch owns either way has no owner, and absorb will not invent one.
--at <branch>overrides all of it when you know where the fix belongs, and--at <branch>:<glob>overrides only the files matching the glob; see the reference. - Commit. Stash the tree, then for each owning branch in turn: check it out, write the file back,
git add, andgit commit --amend --no-edit. This is an amend of the branch's tip commit, not a new commit on top. - Restack. Cascade the descendants of every amended branch onto their parents' new heads, detached in a leased work slot.
- Put the rest back. Write the unowned files back into the worktree, still uncommitted, exactly as they were. The stash in step 2 took the whole tree, so this is what returns the part absorb had no business committing.
Step 2 runs in the worktree you invoked absorb from, because it needs a real tree to write files into. Step 3 does not. So absorb moves your checkout to each owning branch in turn while it commits, then returns you to the branch you started on when it is done. That last part is a property of the command: gitq absorb always leases a work slot and restacks detached inside it, so nothing after the commit phase touches your checkout. (Calling AbsorbEngine.absorb from code without a work slot restacks in the launch tree instead, and leaves you on the last branch it rebased.) (fold can also move your checkout, but permanently, onto the folded branch's parent; see fold.)
Step 4 waits for step 3 to finish rather than running with it: a dirty launch worktree makes the restack's ref finalization refuse. The stash from step 2 is kept alive for that whole stretch and dropped only once step 4 has put the files back, so the unattributed work always has a copy on disk, not just one in absorb's memory. It restores them even when the restack blows up, and if the restore itself fails it keeps the stash and tells you so.
Always --preview first
--preview computes the attribution and prints it without touching anything. It takes no lease, writes no operation-log entry, and is the only mutation-family command that is allowed to run while the stack is parked.
The stack here is feat-api (which added api.ts) with feat-handlers on top of it (which added handlers.ts). The tree is dirty with an edit to each, plus a brand new file:
git status --short
M api.ts
M handlers.ts
?? notes.md
gitq absorb --stack demo --preview
absorb preview: 2 branch(es) attributed, 1 file(s) left in the worktree
The human line is a headcount. The mapping itself is in --json, under result:
gitq absorb --stack demo --preview --json
"result": {
"attributed": {
"feat-api": [
"api.ts"
],
"feat-handlers": [
"handlers.ts"
]
},
"unattributed": [
"notes.md"
],
"currentBranch": "feat-handlers"
}
(The full document also carries the whole stack object; only result is shown here.)
Read it before you apply it. gitq's attribution is mechanical: it is git diff --name-only <parent> <branch> per branch, so it knows which branch's commits touched a path and nothing else. On boring mappings it is right. On a surprising one, a file heading for a branch that has nothing to do with it, stop and commit that file by hand instead.
Files it cannot attribute
unattributed means absorb will not touch the file. It is not a branch assignment and not a warning about one: no branch's commits touch that path, absorb has no evidence about where it belongs, and it will not guess. The file stays dirty in the worktree, and currentBranch in the preview is just where you happen to be standing.
notes.md above was untracked and touched by no branch, so it is still sitting there after the run below. Placing it is your call: commit it on a branch yourself, or leave it for the next absorb once some branch's commits actually own it.
A file you expected to be absorbed showing up in unattributed is worth reading twice. It usually means the branch you think owns that work has never committed that path.
Applying it
gitq absorb --stack demo
absorbed: feat-api (1), feat-handlers (1)
Exit 0. The counts are files per branch: one each, and notes.md in neither of them.
You are back where you started, with the unattributed file exactly as you left it:
git status --short --branch
## feat-handlers
?? notes.md
No new commits were created. Each branch's tip commit was amended in place, which is why both hashes changed:
git log --oneline --all --graph
* 4d72dbd add handlers
* 386ff35 add api module
* 448dd10 initial commit
git show --stat --oneline feat-handlers
4d72dbd add handlers
handlers.ts | 1 +
1 file changed, 1 insertion(+)
and feat-api's tip now carries the edited file:
git show feat-api:api.ts
export const api = 2;
feat-handlers was restacked onto the amended feat-api automatically. That restack is not the cascade that sync and reparent share: absorb runs its own loop over just the descendants of amended branches, onto their parents' current local heads, with no fetch and, as the next section covers, no pause protocol.
Run it again and notes.md is all that is left, which is nothing absorb can place:
gitq absorb --stack demo
nothing absorbed (nothing-attributable)
Exit 0, and it means what it says: nothing was stashed, no branch was checked out, no commit was made. --json carries the same thing under result:
"result": {
"absorbed": false,
"reason": "nothing-attributable",
"attributions": [],
"unattributed": [
"notes.md"
]
}
The preview says it up front too, and this is the shape worth recognizing: zero branches, files left over.
gitq absorb --stack demo --preview
absorb preview: 0 branch(es) attributed, 1 file(s) left in the worktree
On a genuinely clean tree the reason is the other one:
nothing absorbed (no-changes)
When the restack conflicts
Absorb has no pause protocol, and the commit phase is not the clean all-or-nothing step it might look like either. It amends one owning branch at a time, and if a later amend fails it stops there without rolling back the branches it already amended. A pre-commit hook that fails on the second branch is a realistic way to hit this: the amend runs in the worktree you launched absorb from, which keeps its hooks live, unlike a leased work slot. When that happens, gitq absorb reports nothing absorbed and exits 1, which undersells what happened. It attempts to check your original branch back out and pop the stash, which normally brings the whole dirty tree back, files it had already committed elsewhere included. Check git log --oneline --all or git show --stat <branch> for the branches you expected to change before you decide what to do next.
The attempt can fail, and then absorb says so instead of exiting quietly on the wrong branch. Here the launch branch was feat-api (the ancestor), the hook refused feat-handlers's amend, and the staged leftovers of that amend then blocked the checkout back:
gitq absorb --stack demo
nothing absorbed
absorb could not clean up after the failed amend: could not check feat-api back out (git checkout feat-api failed: error: Your local changes to the following files would be overwritten by checkout:
handlers.ts
Please commit your changes or stash them before you switch branches.
Aborting) — you are on feat-handlers; left the stash alone rather than popping your dirty tree onto the wrong branch. Your uncommitted work is retained in stash@{0} — inspect it with `git stash show -p stash@{0}`, then get it back with `git checkout -f feat-api` (the failed amend can leave staged files in the way, and the stash holds them too) and `git stash pop`.
Exit 1, and you are standing somewhere you did not ask to be:
git status --short --branch
## feat-handlers
M handlers.ts
git stash list
stash@{0}: WIP on feat-api: e2f7f35 feat-api: add api.ts
Your whole dirty tree is in that entry, and absorb deliberately did not pop it onto a branch you never picked. Do what the message says: git checkout -f feat-api, then git stash pop.
Past a clean commit phase, the restack can still conflict like any rebase, and when it does absorb backs out rather than leaving you mid-rebase with a success exit code:
gitq absorb --stack two
gitq: absorb restack conflicted on feat-top; aborted the rebase (branch edits kept). run gitq sync to restack with full conflict handling
Exit 1. Unpack that message, because the state it leaves is specific:
-
The rebase is aborted. Both in the work slot and, defensively, in your launch worktree. Nothing is left mid-rebase, and that run had nothing unattributed, so the tree came back clean:
bashgit status --short --branch## feat-top -
Unattributed files are restored anyway. Step 4 runs whether or not the restack succeeded, so a conflict here does not cost you the changes absorb declined to commit. They are dirty in the launch worktree, same as after a clean run.
-
The absorbed commits stay put. The amends already landed and are not rolled back. In the run above,
feat-basekept the change it absorbed:bashgit show feat-base:shared.tsconst v = 2; -
The descendants were not restacked. They are still sitting on their parents' old heads, which is exactly the
behind-parentsituationsyncexists for. -
The lease is released. Absorb is finished; the stack is not parked and nothing is blocked.
So the follow-up is the one the message names:
gitq sync --stack two
paused on feat-top in /Users/you/.mattstack/gitq/work/59c75405b3ab3d4f/gitq-1 (commit ?/?):
UU shared.ts
resolve with git in that worktree, stage, then: gitq continue (or gitq abort)
Exit 2, and from here it is the ordinary pause loop: resolve in the named worktree, git add, gitq continue. See Resolve a conflict.
What refuses up front
Absorb rewrites branches by checking them out in your launch worktree, so a branch it needs cannot be held by a different worktree. It runs its own preview first and refuses before touching anything if any attributed branch is checked out elsewhere:
gitq absorb --stack demo
gitq: branch "feat-api" is checked out in slot "wt-api" (/Users/you/repos/demo/wt-api); run this from that worktree or free the branch first
Exit 1, and it fires whether or not that worktree is dirty; holding the branch is enough. Either run absorb from that worktree (gitq -C <that worktree> absorb) or free the branch there.
It also refuses, like every mutation, while the stack holds a lease:
gitq absorb --stack demo
gitq: stack has a parked sync lease on /Users/you/.mattstack/gitq/work/0b41fcd9372f1dff/gitq-1; finish it first: gitq continue (or gitq abort)
paused on feat-api, 1 conflict:
UU config.ts
absorb --preview is exempt from that one, because it mutates nothing:
gitq absorb --stack demo --preview
absorb preview: 1 branch(es) attributed, 0 file(s) left in the worktree
Exit 0, mid-pause. See The pause protocol.
From the board
The board can absorb from any dirty worktree of the repo, not just the primary checkout. Its stack menu lists one absorb from <worktree> entry per non-work-slot worktree that is currently dirty, and reads absorb (no dirty worktree) when there are none. Picking one launches the absorb runner against that path, so the changes it sources, and the tree its unattributed files are left dirty in, are that worktree's.
That is the same thing as running gitq -C <that worktree> absorb yourself. See The board and Worktree pools.
Undo
absorb is recorded in the operation log, and reversible, only when it exits 0. When it is, gitq undo resets the affected branches to their pre-absorb heads. What it will not do is give you the dirty working tree back; the changes are commits now.
An absorb that hits a restack conflict (see above) exits 1 and writes no log entry at all, even though the commit phase already landed. There is nothing for gitq undo to give back, and it will not tell you that: it just takes the most recent entry for the repo, which after a failed absorb belongs to whatever you ran before it. Reach for gitq undo here and you silently rewind that earlier operation instead. The actual recovery is the one named above: gitq sync --stack <name> to finish the restack.
Next
- Resolve a conflict: the loop for when the follow-up sync pauses.
gitq absorb: flags and JSON shape.- Restructure a stack: when the answer is not "absorb it" but "the tree is wrong".