I had two personal Git repositories that I decided were really only different parts of the same project. I therefore wanted to put one in a subdirectory of the other while preserving its history. Now, subtrees and submodules are for tracking other, independent repositories nested under the main repository. Meanwhile, grafts are for joining commits with unrelated histories: more an implementation detail than the actual goal here. Instead, I wanted one repository to ‘absorb’ the other.

Stack Overflow provided a solution I could adapt. I followed this process to merge repo1 into a subdirectory of repo2, both on main:

  1. Check out repo1.
  2. Use git-filter-repo to move everything into a new repo1 subdirectory: git filter-repo --to-subdirectory-filter repo1.
  3. Add repo2 as a new remote: git remote add -f repo2 url-of-repo2.git.
  4. Merge main from repo2 into the current branch: git merge --allow-unrelated-histories repo2/main.
  5. Re-sign all the commits: git rebase --root --exec 'git commit --amend --no-edit -n -S' -i main. (git-filter-repo doesn’t sign anything.)
  6. Force-push to repo2: git push -f repo2 main.

I wanted repo1 commit messages to have the string repo1 in the scope (using the Conventional Commits message style), for which I next ran git-filter-repo with this --message-callback:

Pythonimport re; pat = re.compile(r"\s*([^(!:]+)(?:\(([^)]+)\))?(!)?:(.+)\s*".encode("utf8"), re.DOTALL); match = pat.fullmatch(message); new_message = (match.group(1) + b"(repo1" + ((b"/" + match.group(2)) if match.group(2) else b"") + b")" + (match.group(3) if match.group(3) else b"") + b":" + match.group(4)) if match is not None else message; return new_message.replace(b"/)", b")")

A message like feat(something)!: add feature would become feat(repo1/something)!: add feature, while fix: make feature work would become fix(repo1): make feature work.

I expected to be able to run the message transformation exclusively on the new subdirectory, but specifying that directory as the path meant git-filter-repo would only consider changes in that directory while creating the new history, effectively undoing my merge. In the end, I repeated the merge process from scratch and added the message callback to the original git-filter-repo step.