Add git touching to list the branches that change a path - #134
Conversation
9c1341e to
ef2b178
Compare
Before touching a file it helps to know which in-flight branches have already changed it, so a conflict can be seen coming rather than found at merge time. Answering that meant pasting a loop over `git for-each-ref` that ran `git log` against a hardcoded `main` for every local branch. That missed branches that exist only on origin and failed in master-based repositories. `git touching <path>...` lists the local and origin branches carrying a commit that changes one of the paths and has reached neither the local base branch nor origin's copy of it. Excluding both keeps a branch that was merged upstream from being listed only because the local base has not been pulled, and copes with a base that exists on just one side. The base comes from `git base`, which now names git-touching among its callers, and `git rev-list -n 1` stops each walk at the first match. Branches are walked by full ref name, so a same-named tag cannot answer for one, and printed with the refs/heads/ or refs/remotes/ prefix stripped rather than as `heads/<name>`. Paths are pathspecs, so directories and quoted globs work, and a deleted or renamed path still matches the commits that removed it. Nothing fetches, so origin branches are as current as the last fetch. The walk passes `--no-merges --full-history`. Under the default history simplification the answer turned on whether the base had moved since a branch last merged it: once it had, a branch that merged the base was listed for paths only the base changed, and until it had, a branch's own change was hidden by a merge that took the base's version of the path. Merges no longer count at all, so content introduced only by a conflict resolution is not seen. A branch that landed as rewritten commits, through "Rebase and merge" or a squash, stays listed until `git cleanup` removes it. The branch listing is read into a variable and the loop reads a here-document, as in git-cleanup, because a failed listing piped into the loop would exit 0 with no output and look like no match. The body lives in ~/.local/bin beside the other git helpers, with a ~/.gitconfig stub as the fallback for contexts without that PATH. Unlike those helpers this one reads its path arguments relative to the working directory, so the script returns to GIT_PREFIX first: the alias route runs from the repository top level, and without that a path given from a subdirectory would name the wrong file. The `cd` target is prefixed with `./` so an exported CDPATH cannot redirect it. Verified in a scratch clone with origin branches, over the dashed external, the ~/.gitconfig route and a wrapper alias that passes its arguments through, from a subdirectory, with a directory pathspec, a quoted glob, a deleted file on an origin-only branch, a branch that merged the base before and after the base moved on, a branch whose merge took the base's version of the path, a branch merged upstream after the last pull, a tag named like a branch, an exported CDPATH, a failing branch listing, no arguments, and a path outside the repository; shellcheck passes for sh and dash. `homesick link --force dotfiles` is needed on any machine that tracks this repository.
ef2b178 to
43d326e
Compare
Code Review:
|
| Reviewer | Focus |
|---|---|
| code-best-practices-reviewer | Correctness, POSIX portability, shellcheck, performance |
| security-reviewer | Argument injection, hostile ref names, environment influence, leaks |
| documentation-expert | Header and .gitconfig comments, commit message, README |
| test-suite-architect | 40+ black-box scenarios in throwaway repos with a bare origin |
Verification: every behavioral claim below was reproduced in a throwaway repo. F1's fix and
F7's resolution were re-run independently after the reviewers disagreed on them (see those
findings).
Correctness
F1 🟠 High Priority - A branch that merged the base is listed for paths only the base changed ✅ Fixed
Status: Fixed in 43d326e — added --no-merges --full-history to the walk and a header
paragraph explaining it
Location: home/.local/bin/git-touching:51 (git rev-list -n 1 "$ref" --not $exclude -- "$@")
Issue: Default history simplification drops a merge's base side only while that parent is still
an excluded tip. Once the base moves past the merged commit, the merge counts as changing the path
relative to the branch's first parent, so the branch is printed. This is the ordinary "merge main
into my branch" workflow, and the output flips on an unrelated push to main:
| Step | git touching util.rb |
|---|---|
clean edits only README, then merges main (which changed util.rb) |
(nothing) |
main gets one unrelated commit |
clean ✗ |
Same history with --no-merges --full-history |
(nothing) ✓ |
The same simplification also causes the opposite error (found by both test-suite-architect and
code-best-practices-reviewer): a branch commit that changes the path is hidden when a later merge
resolves the path to the base's version, and whether it is hidden depends on whether the base has
moved since. Verified: current script [] before the base moves, [simp] after; with the fix,
[simp] both times.
Suggestion:
commit=$(git rev-list -n 1 --no-merges --full-history "$ref" --not $exclude -- "$@") || exit 1--no-merges stops merges from matching; --full-history stops a merge from pruning the branch's
own commits. Test-suite-architect ran the option matrix: neither flag alone is correct across all
10 merge cases, and the pair is. Code-best-practices-reviewer's advice not to add --full-history
was about the flag on its own, whose merge noise --no-merges removes. Trade-off: content
introduced only by a merge's conflict resolution no longer counts, which the header's "a commit on
it … changes" definition already excludes. Add a header sentence explaining both flags.
Recommendation: Implement. One line fixes wrong output in the most common branch workflow.
F2 🟡 Medium Priority - A failed branch listing exits 0 with no output ✅ Fixed
Status: Fixed in 43d326e — branch listing read into a variable; the loop reads a here-document
Location: home/.local/bin/git-touching:44
Issue: git for-each-ref … | while read reports only the loop's status. If for-each-ref
fails, the loop gets no input and the script exits 0 with no output, which looks exactly like "no
branch touches this path". Reproduced with a fake git that fails for-each-ref: rc=0. The
|| exit 1 inside the loop only works because the pipeline is the script's last command.
git-cleanup's header records this exact failure and ends "Do not pipe into the loop again."
Suggestion: Read the list into a variable first, then loop over a here-document so the loop
runs in the main shell:
refs=$(git for-each-ref --format='%(refname)' refs/heads refs/remotes/origin) || exit 1
while read -r ref; do
...
done <<EOF
$refs
EOFRecommendation: Implement. Small, and it follows a rule the repo already wrote down.
F3 🟢 Low Priority - An exported CDPATH hijacks the alias route's cd ✅ Fixed
CDPATH hijacks the alias route's cdStatus: Fixed in 43d326e — cd -- "./${GIT_PREFIX}"
Location: home/.local/bin/git-touching:35 (cd -- "${GIT_PREFIX:-.}")
Issue: GIT_PREFIX is relative with no leading ./ (for example sub/), so cd searches
CDPATH first. It can land outside the repository, and it prints the directory it chose to stdout,
mixing it into the branch list. Reproduced by both security-reviewer and
code-best-practices-reviewer, under bash and dash. Nothing in these dotfiles sets CDPATH.
Suggestion: cd -- "./${GIT_PREFIX}" || exit 1. ./ skips the CDPATH search, and when
GIT_PREFIX is unset it becomes ./, a no-op.
Recommendation: Implement. One token; the failure would otherwise be silent and confusing.
F4 🟢 Low Priority - A local branch named -n is dropped under dash 🚫 Ignored
-n is dropped under dashStatus: Ignored — the name is nearly impossible to create, and git-base uses echo too
Location: home/.local/bin/git-touching:54 (echo "${ref#refs/heads/}")
Issue: dash's echo treats -n as an option. macOS /bin/sh is bash, which prints it. Remote
branches keep their origin/ prefix, so they are safe. git branch refuses such names; only
plumbing like update-ref can create one.
Suggestion: printf '%s\n' "${ref#refs/heads/}" (and the same for the remote branch).
Recommendation: Skip, unless F2's edit is already rewriting the loop. The name is nearly
impossible to create, and git-base uses echo too.
Performance
F5 🟡 Medium Priority - One rev-list per ref is slow with many branches ⏸️ Deferred
rev-list per ref is slow with many branchesStatus: Deferred — revisit if it feels slow in a repository with many origin branches
Location: home/.local/bin/git-touching:44-58
Issue: About 9–12 ms per ref, mostly process startup. A commit-graph file didn't help.
| Repo | Refs | Time |
|---|---|---|
| 5,000 commits | 302 | 2.7s |
| 50,000 commits | 1,502 | 18.5s |
Suggestion: Collect every matching commit with one rev-list across all refs, then ask
git for-each-ref --contains … which refs contain them. That took 0.15–0.5s on the large repo with
identical output. It hasn't been checked on merge-heavy histories (especially after F1) or against
argument-length limits.
Recommendation: Defer. Medium severity but not worth doing now: typical repos finish in a few
seconds, and the rewrite would need re-validating against F1's merge cases.
Documentation Accuracy
F6 🟡 Medium Priority - "Unlike the other commands here, this one depends on the working directory" is false ✅ Fixed
Status: Fixed in 43d326e — header and commit message now say it reads its arguments
relative to the working directory
Location: home/.local/bin/git-touching:19-20; commit message paragraph 3
Issue: git-bdone:14-23 spends a paragraph on why its working directory matters, and
git-com:5-7 says it shares that behavior. What is unique to git-touching is that it reads its
arguments relative to the working directory.
Suggestion:
# Usually reached as `git touching`; see git-base for why the ~/.gitconfig entry is a fallback.
# Unlike the other commands here, this one reads its arguments relative to the working directory.The commit message becomes "Unlike those helpers this one reads its path arguments relative to the
working directory".
Recommendation: Implement. A reader comparing sibling headers will spot the contradiction.
F7 🟢 Low Priority - "Outside an alias GIT_PREFIX is unset" isn't always true ✅ Fixed
Status: Fixed in 43d326e — header reworded; the cd stays in the script
Location: home/.local/bin/git-touching:21-22
Issue: Git sets GIT_PREFIX for ! aliases but doesn't clear it for dashed externals, so
git touching called from inside another alias inherits it. All three code-focused reviewers
raised this:
- A wrapper that passes arguments through (
!f() { git touching "$@"; }; f) works correctly: the
wrapper's shell is at the top level, and thecdreturns to the user's directory. - A wrapper that changes directory first (
!cd lib && git touching …) applies the prefix twice,
failing loudly or printing nothing.
Resolution of conflicting advice: test-suite-architect proposed moving the cd out of the
script into the .gitconfig stub. Re-tested: that breaks the first, ordinary wrapper case (output
[] instead of [feat-m feat-sub]). Security-reviewer recommended Skip;
code-best-practices-reviewer recommended rewording. Keep the code and fix the claim.
Suggestion: Replace the last sentence with something like: "A dashed external normally sees
GIT_PREFIX unset; one inherited from an enclosing alias is still right unless that alias changed
directory before calling this."
Recommendation: Implement the wording only. It is cheap while F6 and F9 are editing the same
paragraph; no current alias triggers the broken case.
F8 🟢 Low Priority - The rule for when a branch is listed needs precise wording ✅ Fixed
Status: Fixed in 43d326e — "changes" used; header notes rebased, squashed or cherry-picked
branches stay listed
Location: home/.local/bin/git-touching:7
Issue:
- "modifies" differs from "change" in the summary line, the
.gitconfigcomment and the commit
message, and can read as excluding added or deleted files, which the next sentence says match. - A branch that landed via "Rebase and merge", a squash or a cherry-pick is still listed, because
its original commits never reach the base. This repo uses "Rebase and merge", so this is the most
likely surprise. Reproduced with a cherry-pick.
Suggestion: Use "changes" and add: "A branch whose commits landed rewritten — rebased, squashed
or cherry-picked — is still listed until git cleanup removes it." Mention F1's merge handling in
the same paragraph.
Recommendation: Implement. Two short edits that head off the most likely confusion.
F9 🟢 Low Priority - The header credits tag safety to printing rather than walking full ref names ✅ Fixed
Status: Fixed in 43d326e — header paragraph replaced with the suggested text
Location: home/.local/bin/git-touching:24-28
Issue: The text explains only the output ("printed with the … prefix stripped, so a branch
sharing its name with a tag prints as itself"). The more important reason to walk full ref names is
so rev-list can't resolve a short name to a same-named tag and search the tag's history. A
maintainer could conclude the walk may use short names. This paragraph also has F10's short line.
Suggestion:
# Branches are walked by full ref name, so a tag sharing a branch's name cannot answer for it, and
# printed with the refs/heads/ or refs/remotes/ prefix stripped rather than as `%(refname:short)`,
# which would disambiguate such a branch as `heads/<name>`. origin/HEAD is skipped because it only
# points at another origin branch. A failing walk, such as for a path outside the repository, ends
# the loop rather than repeating the error once per branch.Recommendation: Implement. Same edit as F10, and it records why the walk uses full names.
Staleness
F10 🟡 Medium Priority - git-base's caller list omits git-touching ✅ Fixed
git-base's caller list omits git-touchingStatus: Fixed in 43d326e — git-touching added to git-base's caller list
Location: home/.local/bin/git-base:6 (not touched by this branch)
Issue: "Read as $(git base) || exit 1 by git-com, git-bdone, and git-cleanup."
git-touching:36 now calls it the same way. This list is the only record of who depends on
git-base, and its header warns that callers rely on its guards.
Suggestion:
# origin/HEAD when it still points at a ref that exists, else whichever of main or master exists
# locally or on origin. Read as `$(git base) || exit 1` by git-com, git-bdone, git-cleanup, and
# git-touching.Recommendation: Implement. Anyone changing git-base uses this list to find what could break.
Formatting and Consistency
F11 🟡 Medium Priority - Mixed periods at the end of .gitconfig alias comments ✅ Fixed
.gitconfig alias commentsStatus: Fixed in 43d326e — periods restored; the new comment ends with one
Location: home/.gitconfig:47, 51, 59, 62-63
Issue: On main, every prose comment in the alias section ended with a period (checked). This
commit removed the period from springcleaning, base and pc but kept it on the header, com,
bclean, bdone and cleanup. The new touching comment has none. The commit message doesn't
mention the change. Raised by documentation-expert and code-best-practices-reviewer.
Suggestion: Restore the three periods and end the new comment with one.
Recommendation: Implement. Keeps the section consistent and the feature commit focused.
F12 🟢 Low Priority - touching alias comment: misleading commas and a two-word second line ✅ Fixed
touching alias comment: misleading commas and a two-word second lineStatus: Fixed in 43d326e — alias comment rewritten as one line
Location: home/.gitconfig:62-63
Issue: The commas in "with commits, not yet on the base branch, that change the given paths"
make the base-branch condition read as optional, when it defines which commits count. The comment
also wraps just # given paths onto a second line.
Suggestion: One line, 98 characters:
# List the local and origin branches with commits not yet on the base branch that change a path.Recommendation: Implement. Same edit as F11.
F13 🟢 Low Priority - Awkwardly short line in the header ✅ Fixed
Status: Fixed in 43d326e — reflowed as part of F9
Location: home/.local/bin/git-touching:27
Issue: # points at another origin branch. A failing walk, such as is 58 characters; the next
line is 99. It looks like a hand-edit that wasn't reflowed. Raised by two reviewers.
Suggestion: Covered by F9's replacement paragraph.
Recommendation: Implement, as part of F9.
Commit Message
F14 🟢 Low Priority - Commit message fixes for the amend ✅ Fixed
Status: Fixed in 43d326e — commit message rewritten in the amend
Location: 65ddf5d message
Issue:
- Paragraph 2 says "printed with that prefix stripped" before any prefix has been named.
- Paragraph 2 has a 57-character line followed by a 71-character one.
- The final sentence gives
homesick link dotfiles; the README's update steps use
homesick link --force dotfiles, and without--forceHomesick prompts for every existing path. - Paragraph 3 repeats F6's false claim.
- If F1 and F2 are implemented, the message should mention merge handling.
Suggestion: Name the prefix ("with the refs/heads/ or refs/remotes/ prefix stripped"), reflow,
use homesick link --force dotfiles, and update per F6 and F1.
Recommendation: Implement, in one amend alongside the code fixes.
Observations
F15 💡 Observation (optional action) - git base prints four errors outside a repository
Outside a repository, git-base's unsuppressed for-each-ref calls print fatal: not a git repository four times before its own message, for every caller. Optional follow-up in git-base,
not this branch: an early git rev-parse --git-dir >/dev/null || exit 1.
F16 💡 Observation (optional action) - Old "the one command" claim in git-bdone
git-bdone:14 says "This is the one command in the set whose working directory is not irrelevant",
which git-com already contradicted. Optional follow-up: "one of the commands in the set whose
working directory matters".
F17 💡 Observation (optional action) - A small test harness would have caught F1
F1 appears only after a multi-step merge history that nobody tries by hand. The throwaway harness
used for this review, about 50 lines (a fresh repo with a bare origin, plus commit and run
helpers), covered git-base and git-touching. Optional: turn it into bin/test-git-scripts covering
the merge cases, the alias route (with git-touching removed from PATH, or git never reaches the
alias) and git-base's guards. Best done the next time git-cleanup changes, since it's the
riskiest script.
F18 💡 Observation (optional action) - Globs must be quoted
"a directory or glob works too" (git-touching:8) is true only when the glob is quoted. Unquoted,
the shell expands it against files that exist now, losing matches for deleted paths. Optional: "a
directory or quoted glob".
F19 ℹ️ Observation - Security review is clean
No Critical, High or Medium security issues:
- Paths starting with
-stay after--(git touching --output=…created no file). - Pathspec magic only narrows the search, and it comes from the user's own arguments.
- Unquoted
$excludeis safe: ref names can't contain whitespace or glob characters, and each ref
is verified before it's added. - Ref names can't contain ESC or BEL. Non-ASCII names such as U+202E print exactly as
git branch -aprints them. - The diff contains no home-directory paths, private repo names or credentials.
F20 ℹ️ Observation - Verified-correct behavior
shellcheck -s shand-s dashpass.- About 35 scenarios passed:
- local-only, origin-only and both
- merged upstream while the local base is stale
- deleted and renamed files, directories, globs,
:(top),:! - relative paths from a subdirectory, run both directly and through the alias (including a path
with spaces) - a single error and exit 1 for a path outside the repository
- a branch named like a tag,
origin/HEADpresent or removed, a base that exists only on origin - running on the base branch, a
masterbase, no base
- Every comment line is 100 characters or fewer (
.gitconfig:8reads as 104 bytes but is 100
characters, because of its em dashes).
F21 ℹ️ Observation - Accepted behaviors (no action)
- A path that matches nothing, including a typo, prints nothing and exits 0. Validating paths would
break matching on deleted files. git touching --helpis intercepted by git ("No manual entry").- Branches on remotes other than
originare ignored, which matchesgit-base's origin-only
design. - A local branch literally named
origin/<x>prints the same as a remote branch. - If
origin/HEADpoints at a feature branch, that branch becomes the base, which matches
git-base's contract.
F22 ℹ️ Observation - Header and commit message quality
The header matches its siblings in density, section order and idiom ("Usually reached as…",
"dashed external", explaining why an unquoted variable is safe). Adding touching to the
.gitconfig stub list is correct and keeps its existing comma style. The README intentionally
doesn't list individual helpers, so it needs no change. The commit message is clear, explains the
old workaround and lists what was verified.
Summary
| Finding | Priority | Category | Description | Location | Recommendation | Status |
|---|---|---|---|---|---|---|
| F1 | 🟠 High | Correctness | Merged-base false positive; own change hidden by merge | git-touching:51 | Implement | ✅ Fixed |
| F2 | 🟡 Medium | Correctness | Failed branch listing exits 0 with no output | git-touching:44 | Implement | ✅ Fixed |
| F3 | 🟢 Low | Correctness | CDPATH hijacks the alias route's cd |
git-touching:35 | Implement | ✅ Fixed |
| F4 | 🟢 Low | Correctness | Branch named -n dropped under dash |
git-touching:54 | Skip | 🚫 Ignored |
| F5 | 🟡 Medium | Performance | One rev-list per ref (18s at 1,500 refs) |
git-touching:44-58 | Defer | ⏸️ Deferred |
| F6 | 🟡 Medium | Documentation Accuracy | False working-directory uniqueness claim | git-touching:19-20; commit msg | Implement | ✅ Fixed |
| F7 | 🟢 Low | Documentation Accuracy | Inherited GIT_PREFIX claim; don't move the cd |
git-touching:21-22 | Implement (wording) | ✅ Fixed |
| F8 | 🟢 Low | Documentation Accuracy | "modifies"; rebased/squashed branches still listed | git-touching:7 | Implement | ✅ Fixed |
| F9 | 🟢 Low | Documentation Accuracy | Tag safety credited to printing, not walking | git-touching:24-28 | Implement | ✅ Fixed |
| F10 | 🟡 Medium | Staleness | git-base caller list omits git-touching |
git-base:6 | Implement | ✅ Fixed |
| F11 | 🟡 Medium | Consistency | Mixed periods at the end of alias comments | .gitconfig:47,51,59,62-63 | Implement | ✅ Fixed |
| F12 | 🟢 Low | Consistency | Misleading commas and short wrap in alias comment | .gitconfig:62-63 | Implement | ✅ Fixed |
| F13 | 🟢 Low | Formatting | Awkwardly short header line | git-touching:27 | Implement (via F9) | ✅ Fixed |
| F14 | 🟢 Low | Commit Message | Undefined "that prefix", wrap, --force, F6 claim |
65ddf5d | Implement (amend) | ✅ Fixed |
| F15 | 💡 | Observation | Four duplicate errors outside a repository | git-base | — | |
| F16 | 💡 | Observation | Old "the one command" claim | git-bdone:14 | — | |
| F17 | 💡 | Observation | Test harness for the git scripts | — | — | |
| F18 | 💡 | Observation | Globs must be quoted | git-touching:8 | — | |
| F19 | ℹ️ | Observation | Security review clean | — | — | |
| F20 | ℹ️ | Observation | Verified-correct behavior | — | — | |
| F21 | ℹ️ | Observation | Accepted behaviors | — | — | |
| F22 | ℹ️ | Observation | Header and commit message quality | — | — |
22 findings: 14 actionable (1 High, 5 Medium, 8 Low) and 8 observations — 12 fixed, 1 ignored,
1 deferred; all clear.
Overall Assessment
The script is carefully built. Its quoting, argument handling, ref-name handling and alias/dashed
duality are correct. It passes shellcheck and the security review, and its header is unusually
thorough. The one real bug is F1: rev-list's default history simplification makes the output
depend on whether the base has moved since a branch last merged it. That is common in practice
and fixed with two flags. F2 brings the loop in line with the rule git-cleanup already records.
Most of the rest is keeping the header's claims precise (F6–F9) and undoing unrelated punctuation
churn in .gitconfig (F11). Only F5 (performance) is worth deferring. After F1–F3 and the
documentation fixes, one amended commit would be ready to merge.
Checklist
- F1 - Add
--no-merges --full-historytorev-listand explain it in the header ✅ - F2 - Read the branch list into a variable and loop over a here-document ✅
- F3 -
cd -- "./${GIT_PREFIX}"✅ - 🚫 F4 -
printf '%s\n'instead ofecho(ignored — near-impossible branch name) - ⏸️ F5 - Batch the walk with
for-each-ref --contains(deferred — fast enough today) - F6 - Correct the working-directory claim in the header and commit message ✅
- F7 - Reword the inherited
GIT_PREFIXsentence; keep thecdin the script ✅ - F8 - "modifies" → "changes"; note rewritten-commit branches stay listed ✅
- F9 - Credit walking full ref names for tag safety ✅
- F10 - Add
git-touchingtogit-base's caller list ✅ - F11 - Restore periods on
.gitconfigalias comments ✅ - F12 - Rewrite the
touchingalias comment as one line ✅ - F13 - Reflow the short header line (via F9) ✅
- F14 - Amend the commit message ✅
Summary
git touching <path>..., which lists the local and origin branches with commits that change the given paths and haven't reached the base branch yet. It's a quick way to see which in-flight work will conflict with an edit before you make it.git base, so master-based repositories work too.~/.local/binbeside the other git helpers, with a~/.gitconfigstub as the fallback. Unlike those helpers it depends on the working directory, so it changes toGIT_PREFIXfirst. Otherwise a relative path given from a subdirectory would resolve from the repository top level when the command runs through the alias.homesick link dotfilesis needed on any machine that tracks this repository.Test plan
Tested in a scratch clone with branches on origin:
~/.gitconfigstub with~/.local/binoff PATH, giving the same output as running the script from PATHorigin/mainshellcheckpasses