
Shaping a session's work into commits — git log for the convention, reset --soft for the re-cut
Decide a commit shape by reading the repository history first, then use git reset --soft to re-cut unpushed commits until they match what git log shows.
On this page
Introduction
At the end of a long working session, an agent offered me a three-commit plan for the changes we had made together. It landed as a single commit instead. That choice was easy to make and hard to explain, and the gap bothered me: I could not have written down the rule I had just applied.
The advice everyone repeats is to split work into logical commits. That is true and it is not enough. It tells you that a commit should mean something; it does not tell you how many commits this particular pile of changes should become, or where the seams go. Two repositories with identical diffs can want completely different answers.
The rule turns out to be simple. The shape comes from git log, and git reset --soft is what lets you change your mind before pushing. This article walks through the five steps that produce a commit history matching the repository it lands in.
Why the commit shape belongs to the repository
A diff has no opinion about how it should be divided. What has an opinion is the project: its release process, its ticket system, its review habits, whether anyone ever runs git bisect over it.
Consider two projects. One is a library with a busy main, dozens of contributors, and a history of small focused commits. Splitting a change into three commits there is obviously right, because each one will be read on its own in a pull request. The other ships in versioned releases, and every release is a single commit whose message carries a ticket reference. A tidy three-way split in the second project is not better practice. It is noise, and it breaks the one-to-one mapping between a release, a commit, and a ticket that the whole process depends on.
Neither repository documents this anywhere. Both of them record it, in the only place that cannot go stale.
Step 1: read the history before staging anything
The first command is not git add. It is:
git log --oneline -5The output from the project I was working in looked like this, with the details generalized:
a1b2c3d feat(prompts): apply the glossary and add evaluation questions (PROJ-142)e4f5a6b fix(pipeline): correct a write-back defect and refresh the source data (PROJ-141)b7c8d9e fix(prompts): prevent arithmetic errors and define the aggregation range (PROJ-139)1a2b3c4 feat(pipeline): add a deterministic accuracy test series (PROJ-138)5d6e7f8 fix(prompts): correct a syntax error and add reviewer checks (PROJ-137)Four things are readable from those five lines, and none of them is written down anywhere else in the repository:
| What to look for | What this history says |
|---|---|
| Commits per unit of work | One. Each line is a whole release. |
| Message format | Conventional Commits, Japanese description, trailing ticket reference. |
| Ticket reference | Mandatory, in (PROJ-NNN) form, at the end of the subject. |
| Merge commits | None, so branches are folded in rather than merged. |
That table is the specification. Everything in the following four steps is an attempt to satisfy it.
Step 2: propose a shape and name what each commit is for
With the convention in hand, the agent proposed three commits, grouped by the kind of artifact each touched: the data and configuration files, the prompt files, and the documentation. I approved that grouping and we started staging.
The useful test at this stage is not whether the grouping looks tidy. It is whether you can say what each commit is for in one sentence that a reader who was not there would understand. A commit you cannot describe that way is either two commits or none. This is the part of the standard advice that survives contact with a real repository, and it is worth keeping.
What the standard advice does not cover is that a proposal made at this point is provisional. Two things had not been checked yet.
Step 3: find out what you are not allowed to commit
Staging the third commit produced nothing. git status --short simply did not list the files:
git status --shortThe reason came out of one command:
git check-ignore -v docs/draft-report.md output/question-set.xlsx.gitignore:17:docs/ docs/draft-report.md.gitignore:13:output/ output/question-set.xlsxBoth directories were ignored, deliberately, long before this session. The third commit had no content and could never have had any. The plan went from three commits to two, and the deliverables it was supposed to carry turned out to live outside version control entirely.
There was a second constraint, and it was subtler. Two modified files in the working tree had been changed before the session started, by someone else’s earlier work. The agent had not touched them, so it separated them out and flagged them rather than sweeping them into git add -A. What you may commit is bounded by what you actually changed, and a session that has been running for hours is exactly the situation where that boundary gets forgotten.
Both constraints appeared after the shape had been proposed. That ordering is not a mistake to be avoided. It is why the next step exists.
Step 4: re-cut with git reset --soft
By this point two commits existed, written before the ticket reference requirement had been noticed, and the history wanted one commit rather than two. Neither had been pushed.
Until it is pushed, a commit is still a draft, and git reset --soft is the tool that treats it that way:
git reset --soft <base-sha>The flag is what makes this safe. --soft moves the branch pointer back to the given commit and stops there. The index keeps everything that was staged, the working tree is untouched, and not one file on disk changes. What disappears is the commit boundaries. Everything the discarded commits contained is sitting staged, ready to be committed again in whatever arrangement you now want.
From there the re-cut is two commands:
git add -Agit commitThe result was one commit carrying the session’s work, with the ticket reference the history had asked for:
git log --oneline -39f8e7d6 fix(prompts): align evaluation questions with the reviewer's vocabulary (PROJ-143)a1b2c3d feat(prompts): apply the glossary and add evaluation questions (PROJ-142)e4f5a6b fix(pipeline): correct a write-back defect and refresh the source data (PROJ-141)The same mechanism runs in the other direction. To split one commit into several, git reset --soft HEAD~1 and then stage in pieces with git add -p. Squash and split are the same operation with different follow-through, which is why “should I split or squash” is a question with one tool behind both answers.
The boundary on all of this is publication. Once a commit is pushed to a branch other people build on, rewriting it stops being free. Everything in this step is safe precisely because nothing had left the machine.
Step 5: land it, and decide whether to branch at all
The agent had opened a branch before committing anything:
git switch -c fix/question-vocabularygit switch is worth knowing on its own. It was added in Git 2.23 to take over the branch-changing half of git checkout, which had accumulated two unrelated jobs: moving between branches and restoring files. git switch -c <name> creates and moves to a branch, and it will not silently do something to your files, because file restoration now lives in git restore.
The branch was created because the agent’s default is to avoid committing directly to a repository’s main line. That is a reasonable default and it was the wrong call here, which the history had already said: no merge commits anywhere, every release sitting directly on the main branch. I chose to fold it back:
git switch maingit merge --ff-only fix/question-vocabularygit branch -d fix/question-vocabulary--ff-only is doing real work in that sequence. It refuses to create a merge commit, so if the branch cannot be fast-forwarded the command fails instead of quietly producing the merge bubble that this history does not contain. A branch used as a staging area and folded back with --ff-only leaves no trace, which means branching costs nothing when the history turns out not to want it.
Then push, and the shape is fixed.
Summary
The five steps, in the order they have to happen:
- Read
git log --onelineand write down what it says about commits per unit of work, message format, ticket references, and merge commits. - Propose a shape, and check it by naming what each commit is for in one sentence.
- Find the constraints: what is ignored, and what you did not change. Both appear after the proposal, not before it.
- Re-cut with
git reset --soft <base>until the history matches. No file changes, so this is cheap to repeat. - Land it, folding any working branch back with
git merge --ff-onlyif the history has no merge commits in it.
The finding underneath all five is that there is no universal commit shape to learn. There is a specification, it is different in every repository, and it is sitting in the first screen of git log.
One note on where the human judgement sat, since this session was agent-assisted. The agent surveyed the history, proposed the three-way grouping, found the .gitignore constraint, and wrote the commit messages. The decisions that changed the outcome were mine: collapsing three commits into one, and landing on the main branch rather than keeping the feature branch. The agent read the convention; deciding to follow it was not its call to make.
References
- git-reset documentation, including the table showing which of HEAD, index, and working tree each mode touches
- git-switch documentation, the command that took over branch switching from git checkout in 2.23
- git-merge documentation, covering the –ff-only mode that refuses to create a merge commit
- Git 2.23 release notes, which introduced git switch and git restore as experimental replacements



