Search code examples
gitvisual-studio

How to perform git move in Visual Studio, instead of git delete and git add, when renaming, moving files in Solution Explorer?


Context

I often move, rename files in Visual Studio 2022. Rename is a standard refactoring practice. However when I rename a file in Solution Explorer, not git mv operation is performed, instead git delete and git add.

This causes loosing the history of that particular file/class, which is a great loss in many cases.

Question

I can do the move operation leaving the IDE and using command line

git mv myoldfile.cs mynewfile.cs

which will keep history perfectly, but leaving the IDE is a productivity killer, especially when talking about refactoring and renaming multiple classes/files.

How to perform git mv within Visual Studio, instead of git delete and git add, when renaming, moving files in Solution Explorer?


Solution

  • First, let's clear-up some misconceptions...

    • A git commit is a snapshot of your entire repo at a given point-in-time.
    • A git commit is not a diff or changeset.
    • A git commit does not contain any file "rename" information.
    • And git itself does not log, monitor, record, or otherwise concern itself with files that are moved or renamed (...at the point of creating a commit).

    The above might be counter-intuitive, or even mind-blowing for some people (myself included, when I first learned this) because it's contrary to all major preceding source-control systems like SVN, TFS, CSV, Perforce (Prior to Helix) and others, because all of those systems do store diffs or changesets and it's fundamental to their models.

    Internally, git does use various forms of diffing and delta-compression, however those are intentionally hidden from the user as they're considered an implementation detail. This is because git's domain model is entirely built on the concept of atomic commits, which represent a snapshot state of the entire repo at a particular point-in-time. Also, uses your OS's low-level file-change-detection features to detect which specific files have been changed without needing to re-scan your entire working directory: on Linux/POSIX it uses lstat, on Windows (where lstat isn't available) it uses fscache. When git computes hashes of your repo it uses Merkel Tree structures to avoid having to constantly recompute the hash of every file in the repo.

    So how does git handle moved or renamed files?

    ...but my git GUI clearly shows a file rename, not a file delete+add or edit!

    • While git doesn't store information about file renames, it still is capable of heuristically detecting renamed files between any two git commits, as well as detecting files renamed/moved between your un-committed repo's working directory tree and your HEAD commit (aka "Compare with Unmodified").

    • For example:

      • Consider commit "snapshot 1" with 2 files: Foo.txt and Bar.txt.
      • Then you rename Foo.txt to Qux.txt (and make no other changes).
      • Then save that as a new commit ("snapshot 2").
      • If you ask git to diff "snapshot 1" with "snapshot 2" then git can see that Foo.txt was renamed to Qux.txt (and Bar.txt was unchanged) because the contents (and consequently the files' cryptographic hashes) are identical, therefore it infers that a file rename from Foo.txt to Qux.txt occurred.
        • Fun-fact: if you ask git to do the same diff, but use "snapshot 2" as the base commit and "snapshot 1" as the subsequent commit then git will show you that it detected a rename from Qux.txt back to Foo.txt.
    • However, if you do more than just rename or move a file between two commits, such as editing the file at the same time, then git may-or-may-not consider the file a new separate file instead of a renamed file.

      • This is not a bug, but a feature: this behaviour means that git can handle common file-system-level refactoring operations (like splitting files up) far better than file-centric source-control (like TFS and SVN) can, and you won't see refactor-related false renames either.
      • For example, consider a refactoring scenario where you would split a MultipleClasses.cs file containing multiple class definitions into separate .cs files, with one class per file. In this case there is no real "rename" being performed and git's diff would show you 1 file being deleted (MultipleClassesw.cs) at the same time as the new SingleClass1.cs, SingleClass2.cs, etc files are added.
        • I imagine that you wouldn't want it to be saved to source-control history as a rename from MultipleClasses.cs to SingleClass1.cs as it would in SVN or TFS if you allowed the first rename to be saved as a rename in SVN/TFS.
    • But, and as you can imagine, sometimes git's heuristics don't work and you need to prod it with --follow and/or --find-renames=<percentage> (aka -M<percentage>).

    • My personal preferred practice is to keep your filesystem-based and edit-code-files changes in separate git commits (so a commit contains only edited files, or only added+deleted files, or only split-up changes), that way you make it much, much easier for git's --follow heuristic to detect renames/moves.

      • (This does mean that I do need to temporarily rename files back when using VS' Refactor Rename functionality, fwiw, so I can make a commit with edited files but without any renamed files).

    What does any of this have to do with Visual Studio though?

    • Consider this scenario:

      • You have an existing git repo for a C# project with no pending changes (staged or otherwise). The project has a file located at Project/Foobar.cs containing class Foobar. The file is only about 1KB in size.
      • You then use Visual Studio's Refactor > Rename... feature to rename a class Foobar to class Barfoo.
        • Visual Studio will not-only rename class Foobar to class Barfoo and edit all occurrences of Foobar elsewhere in the project, but it will also rename Foobar.cs to Barfoo.cs.
        • In this example, the identifier Foobar only appears in the 1KB-sized Foobar.cs file two times (first in class Foobar, then again in the constructor definition Foobar() {}) so only 12 bytes (2 * 6 chars) are changed. In a 1KB file that's a 1% change (12 / 1024 == 0.0117 --> 1.17%).
        • git (and Visual Studio's built-in git GUI) only sees the last commit with Foobar.cs, and sees the current HEAD (with the uncommitted changes) has Barfoo.cs which is 1% different from Foobar.cs so it considers that a rename/move instead of a Delete+Add or an Edit, so Visual Studio's Solution Explorer will use the "Move/Rename" git status icon next to that file instead of the "File edited" or "New file" status icon.
        • However, if you make more substantial changes to Barfoo.cs (without committing yet) that exceed the default change % threshold of 50% then the Solution Explorer will start showing the "New file" icon instead of "Renamed/moved file" icon.
          • And if you manually revert some of the changes to Barfoo.cs (again: without saving any commits yet) such that it slips below the 50% change threshold then VS's Solution Explorer will show the Rename icon again.
    • A neat thing about git not storing actual file renames/moves in commits is that it means that you can safely use git with any software, including any software that renames/moves files! Especially software that is not source-control aware.

      • Previously, with SVN and TFS, you needed to restrict yourself to software programs that had built-in support for whatever source-control system you were using (and handled renames itself) or software that supported MSSCCI (and so saved renames via MSSCCI), otherwise you had to use a separate SVN or TFS client to save/commit your file-renames (e.g. TortoiseSvn and Team Foundation Explorer, respectively). This was a tedious and error-prone process that I'm glad to see the end of.
    • Consequently, there is no need for Visual Studio (with or without git support baked-in) to inform git that a file was renamed/moved.

      • That's why there's no IDE support for it: because it simply isn't needed.
    • The fact that a git commit isn't a delta, but a snapshot, means you can far more easily reorder commits, and rebase entire branches with minimal pain. This is not something that was really possible at all in SVN or TFS.

      • (After-all, how can you meaningfully reorder a file rename operation?)