Skip to content
Merged
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
6 changes: 5 additions & 1 deletion docs/src/commands/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ git loom commit -m "wip" -- --no-verify
git loom add zz -- -f
```

`fold` takes one too, for the commit it makes itself — see
[fold](fold.md#git-options).

Before the separator loom parses strictly, so an option it doesn't define is an
error rather than a guess — the message tells you to move it after the `--`.
That also means a flag keeps loom's meaning on loom's side of the separator and
Expand All @@ -78,4 +81,5 @@ there is nobody there to close an editor.) `show` and `diff` are always
uncaptured; displaying is all they do.

The other commands don't take a `--`: they either render their own output or
drive a rebase, where there is no single git command to forward to.
only rebase, where there is no single git command to forward to. A few of them
commit too — `split`, `absorb` and `reword` — and aren't covered yet.
41 changes: 41 additions & 0 deletions docs/src/commands/fold.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,47 @@ When only a target is given, currently staged files are folded into the target c
| `--above <commit>` | Move the source commit(s) directly above (newer than) this commit. |
| `--below <commit>` | Move the source commit(s) directly below (older than) this commit. |

### Git Options

The forms that commit take a `--` separator — see
[Passing Options to Git](README.md#passing-options-to-git):

```bash
git loom fold src/auth.rs ab -- --no-verify
git loom fold -p ab -- --no-verify
```

They reach the amend, or the `fixup!` commit loom makes for a non-HEAD target —
the two places your commit hooks run. The rebase that rewrites the rest runs no
hooks, so a fold that moves whole commits around (fixup, move, uncommit a
commit, `--create`, `--above`/`--below`) takes no arguments after `--` and says
so.

Loom's own arguments go last, so a boolean git resolves last-wins keeps the
value loom asked for: a forwarded `--no-amend` or `--edit` has no effect.

Everything else reaches git as written, gaps included, because `--` is for
someone who knows what they are asking git to do:

- a message source — `-m`, `-F`, `-c`/`-C`, `--fixup`, `--squash` — rewords
every commit loom makes, which for a HEAD target is the target itself, and on
a move between two commits is *both* of them; use [reword](reword.md)
instead;
- a pathspec restricts the commit and brings `--only` semantics with it, so git
commits the working tree's copy of that path rather than what is staged;
- `-a` and `-i` sweep in tracked changes you did not name, and a fold into an
older commit buries them there. That includes your *own staged* files: loom
moves them out of the index for the duration and leaves them in the working
tree, which is where `-a` finds them;
- a second `--` becomes a pathspec and takes loom's own arguments with it, so
git fails on them.

One thing loom does not leave to you: `--dry-run` and the status formats make
git print without committing at all, which would leave a fold rewriting history
around a commit that was never made. Loom checks what git actually did and
takes the whole attempt back — HEAD, its own staging, and your other staged
files — rather than going on.

## Type Dispatch

The action depends on the types of the arguments, detected automatically:
Expand Down
2 changes: 1 addition & 1 deletion skills/git-loom/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ names, paths, and hashes also work. `status -a` includes hidden branches.
|---|---|
| Stage | `git loom add <files>`; list files, never `zz`. |
| Commit | `git loom commit -b <branch> -m "<msg>" <files...>`; a new branch name creates it. Use `-i` for integration. Name files, or omit them to commit exactly the staged set; never `zz`. |
| Amend/fixup | `git loom fold <files...> <commit>`; for staged changes, `git loom fold <commit>`. |
| Amend/fixup | `git loom fold <files...> <commit>`; for staged changes, `git loom fold <commit>`. Git options for the amend follow `--` (`-- --no-verify`). |
| Auto-fixup | `git loom absorb`; `-n` dry-runs. |
| Move commits | `git loom fold <commit>... <branch>`; `-c` creates a new branch and rejects an existing name. `--above <commit>` / `--below <commit>` moves next to a commit, in any branch or the same one. |
| Uncommit | `git loom fold <commit> zz`. |
Expand Down
7 changes: 7 additions & 0 deletions specs/007-fold.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
```bash
git-loom fold <target>
git-loom fold <source>... <target>
git-loom fold <source>... <target> -- <git args>...
git-loom fold --create <commit>... <new-branch>
git-loom fold <commit>... --above <commit>
git-loom fold <commit>... --below <commit>
Expand All @@ -23,6 +24,7 @@ With one argument, fold the current index into that target. With two or more, th
- `--hunks <id>` (repeated) with `--hunks-from <fingerprint>`: supply a commit-source `-p` selection by id instead of picking it. Requires `-p`, and each flag requires the other.
- `zz`: reserved `Unstaged` target/source representing the working directory/all its changes.
- `commit_sid:index` (for example `fa:0`): `CommitFile` shown by `git loom status -f`.
- `-- <git args>...`: forward untouched to the `git commit` fold runs itself — the amend, or the `fixup!` commit for a non-HEAD target (for example `--no-verify`, `-q`; Spec 021). A whole-commit form takes none. Loom's own arguments come last, so a boolean git resolves last-wins (`--no-amend`, `--edit`) has no effect; a message source (`-m`, `-F`, `-c`/`-C`, `--fixup`, `--squash`) still rewords every commit fold makes — both of them on the forms that move a file or hunks between two commits, a pathspec still restricts it with `--only` semantics, and `-a`/`-i` still sweep in changes fold was not given. Before anything is rewritten, the fixup path verifies that git left a new commit on HEAD and, with forwarded arguments, that it holds a different tree; an amend carrying forwarded arguments verifies that HEAD's tree changed. Any failure takes the commit attempt back whole.

## Resolution and dispatch

Expand Down Expand Up @@ -64,6 +66,11 @@ Errors are verbatim; `⏎` marks a line break within a message.
| `--above`/`--below` target among the sources | `Source and target are the same commit` |
| Single source already directly above/below target | ``Commit `<hash>` is already directly above `<hash>` `` (or `below`) |
| Several sources already in place | ``Commits are already in place above `<hash>` `` (or `below`) |
| `--` arguments on a whole-commit form | ``<operation> runs no `git commit`, so it takes no arguments after `--` `` |
| Forwarded argument left no fixup commit | ``` `git commit` left no new commit on HEAD, so nothing was folded⏎An argument after `--` stopped it from committing ``` (second line only with forwarded arguments) |
| Forwarded argument left the fixup commit empty | ``` `git commit` made an empty `fixup!` commit, so nothing was folded⏎An argument after `--` kept the staged changes out of it ``` |
| Amend with `--` arguments changed nothing | ``` `git commit --amend` left the commit as it was, so nothing was amended⏎Either an argument after `--` kept git from committing, or what was staged already matched the commit ``` |
| Amend committed on top instead (git stopped resolving `--amend` last-wins) | ``` `git commit --amend` committed on top of the target instead of amending it ``` |

## File/current-change amendments

Expand Down
90 changes: 85 additions & 5 deletions specs/021-git-args.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,17 @@ itself uses. The cost is one extra `--`.
| `diff` | `git diff` |
| `commit`| `git commit` |
| `add` | `git add` |
| `fold` | `git commit` |

`fold` drives a rebase like the commands below, but it also makes a commit of
its own along the way — the amend, or the `fixup!` commit for a non-HEAD target
— and that one runs the user's commit hooks. See [Fold Commits Too](#fold-commits-too).

Every other command either renders its output itself (`status`, `tui`, `trace`)
or drives a rebase through Weave (`fold`, `absorb`, `split`, `swap`, `drop`,
`reword`, `branch`, `update`, `init`), where there is no single git command the
arguments could belong to.
or drives a rebase through Weave (`absorb`, `split`, `swap`, `drop`, `reword`,
`branch`, `update`, `init`), where there is no single git command the arguments
could belong to. Some of those also make a commit of their own — `split`,
`absorb` and `reword` — and are not covered yet.

## What Happens

Expand All @@ -61,7 +67,9 @@ diagnostic.
A forwarded `--` reaches git as written too, but only `show` leaves it alone:
`diff` appends a `--` of its own when the user named file targets, and `add`
always appends one, so a second separator lands in a command line that already
has one and git rejects the result.
has one and git rejects the result. `commit` and `fold` place loom's own
arguments after the forwarded ones, so a forwarded `--` turns those into
pathspecs and git fails on them instead.

They are placed **after the revisions loom resolved and before any `--`
pathspec loom builds itself**, so a forwarded option is still read as an option
Expand All @@ -73,6 +81,7 @@ git-loom show ab -- -- README.md # git show <rev> -- README.md
git-loom diff f1 -- --stat # git diff --stat -- file1.txt
git-loom commit -m x -- -S # git commit -S -m x
git-loom add f1 -- -f # git add -f -- file1.txt
git-loom fold f1 ab -- -n # git commit -n --amend --no-edit --allow-empty
```

Because the tokens are never inspected, an option's value may be attached or
Expand Down Expand Up @@ -102,6 +111,75 @@ agent on a pty — the failure `commit`'s own agent-mode guard exists to prevent
uncaptured, forwarded arguments or not; capturing them would swallow the user's
pager and colors along with everything else.

`fold` does not step back either, for a different reason: it reads what git
did and rewrites history on that answer (below), which an uncaptured run
cannot report. A forwarded `--dry-run` therefore prints into the trace log and
ends in an error rather than on the terminal.

### Fold Commits Too

The forms that make a commit take the separator: amending files, staged
changes or `zz` into a commit, moving a file's changes between commits or back
to the working tree, and every `-p` form, whose amend runs at a rebase pause. A
whole-commit form only rebases — fixup, move, uncommit, `-c`,
`--above`/`--below` — and rejects the separator: *"\<operation\> runs no `git
commit`, so it takes no arguments after `--`"*.

Nothing is inspected here either. Loom's own arguments are placed **after** the
forwarded ones, so a boolean git resolves last-wins keeps the value loom asked
for: `--no-amend` loses to `--amend`, and `--edit` to `--no-edit`.

That is all ordering buys. Git does not resolve a message source last-wins, so
`-m`, `-F`, `-c`/`-C`, `--fixup` and `--squash` all reword every commit fold
makes — the target itself when it is HEAD, which is `reword`'s job, and the
`fixup!` commit otherwise, where the squash throws the message away. Moving a
file or hunks *between* two commits amends both, so a message source rewords
the source commit as well as the target. A pathspec
restricts the commit and takes `--only` semantics with it, committing the
Comment thread
narnaud marked this conversation as resolved.
working tree's copy of that path rather than the index's. `-a`/`--all` and
`-i`/`--include` go the other way and sweep in tracked changes fold was never
given; on a non-HEAD target that buries them in a rewritten historical commit.
Fold's own unstaging feeds them: the user's other staged files are moved out of
the index and left in the working tree for the duration, which is exactly where
`-a` picks them up.
These are the price of forwarding verbatim, and the reason `--` is for someone
who knows what they are asking git to do.

What loom does not leave to the user is a commit that never happened.
`--dry-run` and the status formats make git print and exit 0 without
committing, which would leave fold rewriting history around nothing, so both
commit paths check what git actually did before anything is rewritten (Data
Safety):

- The fixup commit, on two counts. Unless HEAD is now a new commit whose parent
is the HEAD it was made on, nothing is squashed and the index goes back —
*"`git commit` left no new commit on HEAD, so nothing was folded"*. Without it
a forwarded `--amend` squashes the user's own HEAD commit into the target and
loses it. That parent alone does not prove the commit holds anything: `--only`
with no pathspec commits none of the index and `--allow-empty` lets the result
through, so the tree is compared as well — *"`git commit` made an empty
`fixup!` commit, so nothing was folded"*. Squashing that rewrites the target
with nothing in it and reports the fold as done.
- Every amend that carries forwarded arguments. A fold amend normally has
something to commit, so HEAD's tree has to come out different — *"`git commit
--amend` left the commit as it was, so nothing was amended"*. The message
names both causes rather than blaming the arguments: staging a change and
then putting the file back reaches the same amend with nothing in it, and
without `--` that case passes silently. The tree is read from
HEAD on both sides, because what a hook stages is the commit's business: an
index the amend should have emptied fails on a `post-commit` hook, and a tree
written from the index beforehand fails on a `pre-commit` one. Not HEAD's
hash either, which an amend that changes nothing within the same second
reproduces. Without forwarded arguments the check does not run: git cannot be
told to do anything but amend then.

Either way the commit attempt is taken back whole — HEAD, loom's own staging,
and the user's other staged files — before the error is reported.

**What changes:** nothing.

**What stays the same:** everything.

### When the Command Doesn't Take Them

`--` is not defined on the other commands, so the tokens after it are rejected
Expand Down Expand Up @@ -131,6 +209,7 @@ git-loom diff -- -a # git's --text, which loom's own -a shadows
git-loom diff ab..d0 -- --name-only
git-loom commit -m "wip" -- --no-verify
git-loom add zz -- -f # stage an ignored file too
git-loom fold zz ab -- --no-verify # skip the pre-commit hook on the amend
```

## Design Notes
Expand All @@ -147,4 +226,5 @@ happen to collide.

They go to the git command the loom command wraps, and nothing else. `loom
commit -- --author=…` shapes the commit loom creates, not the rebase that
relocates it onto the feature branch.
relocates it onto the feature branch, and `loom fold -- --author=…` shapes the
amend, not the commits the rebase replays over it.
14 changes: 14 additions & 0 deletions src/core/test_helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -608,6 +608,20 @@ impl TestRepo {
crate::git::run_git(self.workdir().as_path(), &["config", key, value]).unwrap();
}

/// Install an executable git hook and point `core.hooksPath` at this repo,
/// which the user's own config may have aimed elsewhere.
#[cfg(unix)]
pub fn install_hook(&self, name: &str, body: &str) {
use std::os::unix::fs::PermissionsExt;

let hooks = self.repo.path().join("hooks");
std::fs::create_dir_all(&hooks).unwrap();
let hook = hooks.join(name);
std::fs::write(&hook, format!("#!/bin/sh\n{body}")).unwrap();
std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o755)).unwrap();
self.set_config("core.hooksPath", hooks.to_str().unwrap());
}

/// Get porcelain status output.
pub fn status_porcelain(&self) -> String {
crate::git::run_git_stdout(self.workdir().as_path(), &["status", "--porcelain"]).unwrap()
Expand Down
Loading
Loading