project: migrate worktree .git/ dirs to symlinks

Historically we created a .git/ subdir in each source checkout and
symlinked individual files to the .repo/projects/ paths.  This layer
of indirection isn't actually needed: the .repo/projects/ paths are
guaranteed to only ever have a 1-to-1 mapping with the actual git
checkout.  So we don't need to worry about having files in .git/ be
isolated.

To that end, change how we manage the actual project checkouts from
a dir full of symlinks (and a few files) to a symlink to the internal
.repo/projects/ dir.  This makes the code simpler & faster.

The directory structure we have today is:
.repo/
  project-objects/chromiumos/third_party/kernel.git/
    <paths omitted as not relevant to this change>
  projects/src/third_party/kernel/
    v3.8.git/
      config
      description   -> …/project-objects/…/config
      FETCH_HEAD
      HEAD
      hooks/        -> …/project-objects/…/hooks/
      info/         -> …/project-objects/…/info/
      logs/
      objects/      -> …/project-objects/…/objects/
      packed-refs
      refs/
      rr-cache/     -> …/project-objects/…/rr-cache/
src/third_party/kernel/
  v3.8/
    .git/
      config        -> …/projects/…/v3.8.git/config
      description   -> …/project-objects/…/v3.8.git/description
      HEAD
      hooks/        -> …/project-objects/…/v3.8.git/hooks/
      index
      info/         -> …/project-objects/…/v3.8.git/info/
      logs/         -> …/projects/…/v3.8.git/logs/
      objects/      -> …/project-objects/…/v3.8.git/objects/
      packed-refs   -> …/projects/…/v3.8.git/packed-refs
      refs/         -> …/projects/…/v3.8.git/refs/
      rr-cache/     -> …/project-objects/…/v3.8.git/rr-cache/

The directory structure we have after this commit:
.repo/
  <nothing changes>
src/third_party/kernel/
  v3.8/
    .git            -> …/projects/…/v3.8.git

Bug: https://crbug.com/gerrit/15273
Change-Id: I9dd8def23fbfb2f4cb209a93f8b1b2b24002a444
Reviewed-on: https://gerrit-review.googlesource.com/c/git-repo/+/323695
Reviewed-by: Mike Nichols <mikenichols@google.com>
Reviewed-by: Xin Li <delphij@google.com>
Tested-by: Mike Frysinger <vapier@google.com>
This commit is contained in:
Mike Frysinger 2021-11-13 23:29:42 -05:00
parent 4a478edb44
commit 2a089cfee4
2 changed files with 131 additions and 36 deletions

View File

@ -2781,44 +2781,45 @@ class Project(object):
self._InitMRef() self._InitMRef()
def _InitWorkTree(self, force_sync=False, submodules=False): def _InitWorkTree(self, force_sync=False, submodules=False):
realdotgit = os.path.join(self.worktree, '.git') """Setup the worktree .git path.
tmpdotgit = realdotgit + '.tmp'
init_dotgit = not os.path.exists(realdotgit) This is the user-visible path like src/foo/.git/.
if init_dotgit:
With non-git-worktrees, this will be a symlink to the .repo/projects/ path.
With git-worktrees, this will be a .git file using "gitdir: ..." syntax.
Older checkouts had .git/ directories. If we see that, migrate it.
This also handles changes in the manifest. Maybe this project was backed
by "foo/bar" on the server, but now it's "new/foo/bar". We have to update
the path we point to under .repo/projects/ to match.
"""
dotgit = os.path.join(self.worktree, '.git')
# If using an old layout style (a directory), migrate it.
if not platform_utils.islink(dotgit) and platform_utils.isdir(dotgit):
self._MigrateOldWorkTreeGitDir(dotgit)
init_dotgit = not os.path.exists(dotgit)
if self.use_git_worktrees: if self.use_git_worktrees:
if init_dotgit:
self._InitGitWorktree() self._InitGitWorktree()
self._CopyAndLinkFiles() self._CopyAndLinkFiles()
return
dotgit = tmpdotgit
platform_utils.rmtree(tmpdotgit, ignore_errors=True)
os.makedirs(tmpdotgit)
self._ReferenceGitDir(self.gitdir, tmpdotgit, share_refs=True,
copy_all=False)
else: else:
dotgit = realdotgit if not init_dotgit:
# See if the project has changed.
if platform_utils.realpath(self.gitdir) != platform_utils.realpath(dotgit):
platform_utils.remove(dotgit)
try: if init_dotgit or not os.path.exists(dotgit):
self._CheckDirReference(self.gitdir, dotgit, share_refs=True) os.makedirs(self.worktree, exist_ok=True)
except GitError as e: platform_utils.symlink(os.path.relpath(self.gitdir, self.worktree), dotgit)
if force_sync and not init_dotgit:
try:
platform_utils.rmtree(dotgit)
return self._InitWorkTree(force_sync=False, submodules=submodules)
except Exception:
raise e
raise e
if init_dotgit: if init_dotgit:
_lwrite(os.path.join(tmpdotgit, HEAD), '%s\n' % self.GetRevisionId()) _lwrite(os.path.join(dotgit, HEAD), '%s\n' % self.GetRevisionId())
# Now that the .git dir is fully set up, move it to its final home.
platform_utils.rename(tmpdotgit, realdotgit)
# Finish checking out the worktree. # Finish checking out the worktree.
cmd = ['read-tree', '--reset', '-u'] cmd = ['read-tree', '--reset', '-u', '-v', HEAD]
cmd.append('-v')
cmd.append(HEAD)
if GitCommand(self, cmd).Wait() != 0: if GitCommand(self, cmd).Wait() != 0:
raise GitError('Cannot initialize work tree for ' + self.name) raise GitError('Cannot initialize work tree for ' + self.name)
@ -2826,6 +2827,50 @@ class Project(object):
self._SyncSubmodules(quiet=True) self._SyncSubmodules(quiet=True)
self._CopyAndLinkFiles() self._CopyAndLinkFiles()
@classmethod
def _MigrateOldWorkTreeGitDir(cls, dotgit):
"""Migrate the old worktree .git/ dir style to a symlink.
This logic specifically only uses state from |dotgit| to figure out where to
move content and not |self|. This way if the backing project also changed
places, we only do the .git/ dir to .git symlink migration here. The path
updates will happen independently.
"""
# Figure out where in .repo/projects/ it's pointing to.
if not os.path.islink(os.path.join(dotgit, 'refs')):
raise GitError(f'{dotgit}: unsupported checkout state')
gitdir = os.path.dirname(os.path.realpath(os.path.join(dotgit, 'refs')))
# Remove known symlink paths that exist in .repo/projects/.
KNOWN_LINKS = {
'config', 'description', 'hooks', 'info', 'logs', 'objects',
'packed-refs', 'refs', 'rr-cache', 'shallow', 'svn',
}
# Paths that we know will be in both, but are safe to clobber in .repo/projects/.
SAFE_TO_CLOBBER = {
'COMMIT_EDITMSG', 'FETCH_HEAD', 'HEAD', 'index', 'ORIG_HEAD',
}
# Now walk the paths and sync the .git/ to .repo/projects/.
for name in platform_utils.listdir(dotgit):
dotgit_path = os.path.join(dotgit, name)
if name in KNOWN_LINKS:
if platform_utils.islink(dotgit_path):
platform_utils.remove(dotgit_path)
else:
raise GitError(f'{dotgit_path}: should be a symlink')
else:
gitdir_path = os.path.join(gitdir, name)
if name in SAFE_TO_CLOBBER or not os.path.exists(gitdir_path):
platform_utils.remove(gitdir_path, missing_ok=True)
platform_utils.rename(dotgit_path, gitdir_path)
else:
raise GitError(f'{dotgit_path}: unknown file; please file a bug')
# Now that the dir should be empty, clear it out, and symlink it over.
platform_utils.rmdir(dotgit)
platform_utils.symlink(os.path.relpath(gitdir, os.path.dirname(dotgit)), dotgit)
def _get_symlink_error_message(self): def _get_symlink_error_message(self):
if platform_utils.isWindows(): if platform_utils.isWindows():
return ('Unable to create symbolic link. Please re-run the command as ' return ('Unable to create symbolic link. Please re-run the command as '

View File

@ -16,6 +16,7 @@
import contextlib import contextlib
import os import os
from pathlib import Path
import shutil import shutil
import subprocess import subprocess
import tempfile import tempfile
@ -335,3 +336,52 @@ class LinkFile(CopyLinkTestCase):
platform_utils.symlink(self.tempdir, dest) platform_utils.symlink(self.tempdir, dest)
lf._Link() lf._Link()
self.assertEqual(os.path.join('git-project', 'foo.txt'), os.readlink(dest)) self.assertEqual(os.path.join('git-project', 'foo.txt'), os.readlink(dest))
class MigrateWorkTreeTests(unittest.TestCase):
"""Check _MigrateOldWorkTreeGitDir handling."""
_SYMLINKS = {
'config', 'description', 'hooks', 'info', 'logs', 'objects',
'packed-refs', 'refs', 'rr-cache', 'shallow', 'svn',
}
_FILES = {
'COMMIT_EDITMSG', 'FETCH_HEAD', 'HEAD', 'index', 'ORIG_HEAD',
}
@classmethod
@contextlib.contextmanager
def _simple_layout(cls):
"""Create a simple repo client checkout to test against."""
with tempfile.TemporaryDirectory() as tempdir:
tempdir = Path(tempdir)
gitdir = tempdir / '.repo/projects/src/test.git'
gitdir.mkdir(parents=True)
cmd = ['git', 'init', '--bare', str(gitdir)]
subprocess.check_call(cmd)
dotgit = tempdir / 'src/test/.git'
dotgit.mkdir(parents=True)
for name in cls._SYMLINKS:
(dotgit / name).symlink_to(f'../../../.repo/projects/src/test.git/{name}')
for name in cls._FILES:
(dotgit / name).write_text(name)
subprocess.run(['tree', '-a', str(dotgit)])
yield tempdir
def test_standard(self):
"""Migrate a standard checkout that we expect."""
with self._simple_layout() as tempdir:
dotgit = tempdir / 'src/test/.git'
project.Project._MigrateOldWorkTreeGitDir(str(dotgit))
# Make sure the dir was transformed into a symlink.
self.assertTrue(dotgit.is_symlink())
self.assertEqual(str(dotgit.readlink()), '../../.repo/projects/src/test.git')
# Make sure files were moved over.
gitdir = tempdir / '.repo/projects/src/test.git'
for name in self._FILES:
self.assertEqual(name, (gitdir / name).read_text())