add experimental git worktree support
This provides initial support for using git worktrees internally
instead of our own ad-hoc symlink tree. It's been lightly tested
which is why it's not currently exposed via --help.
When people opt-in to worktrees in an existing repo client checkout,
no projects are migrated. Instead, only new projects will use the
worktree method. This allows for limited testing/opting in without
having to completely blow things away or get a second checkout.
Bug: https://crbug.com/gerrit/11486
Change-Id: Ic3ff891b30940a6ba497b406b2a387e0a8517ed8
Reviewed-on: https://gerrit-review.googlesource.com/c/git-repo/+/254075
Tested-by: Mike Frysinger <vapier@google.com>
Reviewed-by: Mike Frysinger <vapier@google.com>
diff --git a/docs/internal-fs-layout.md b/docs/internal-fs-layout.md
index 8e62cde..f474029 100644
--- a/docs/internal-fs-layout.md
+++ b/docs/internal-fs-layout.md
@@ -102,6 +102,11 @@
respective servers ...
* `subprojects/`: Like `projects/`, but for git submodules.
* `subproject-objects/`: Like `project-objects/`, but for git submodules.
+* `worktrees/`: Bare checkouts of every project synced by the manifest. The
+ filesystem layout matches the `<project name=...` setting in the manifest
+ (i.e. the path on the remote server).
+
+ This is used when git worktrees are enabled.
### Global settings
@@ -121,6 +126,7 @@
| repo.partialclone | `--partial-clone` | Create [partial git clones] |
| repo.reference | `--reference` | Reference repo client checkout |
| repo.submodules | `--submodules` | Sync git submodules |
+| repo.worktree | `--worktree` | Use `git worktree` for checkouts |
| user.email | `--config-name` | User's e-mail address; Copied into `.git/config` when checking out a new project |
| user.name | `--config-name` | User's name; Copied into `.git/config` when checking out a new project |
diff --git a/manifest_xml.py b/manifest_xml.py
index 7f38d8c..4162800 100644
--- a/manifest_xml.py
+++ b/manifest_xml.py
@@ -146,9 +146,17 @@
gitdir=os.path.join(repodir, 'repo/.git'),
worktree=os.path.join(repodir, 'repo'))
- self.manifestProject = MetaProject(self, 'manifests',
- gitdir=os.path.join(repodir, 'manifests.git'),
- worktree=os.path.join(repodir, 'manifests'))
+ mp = MetaProject(self, 'manifests',
+ gitdir=os.path.join(repodir, 'manifests.git'),
+ worktree=os.path.join(repodir, 'manifests'))
+ self.manifestProject = mp
+
+ # This is a bit hacky, but we're in a chicken & egg situation: all the
+ # normal repo settings live in the manifestProject which we just setup
+ # above, so we couldn't easily query before that. We assume Project()
+ # init doesn't care if this changes afterwards.
+ if mp.config.GetBoolean('repo.worktree'):
+ mp.use_git_worktrees = True
self._Unload()
@@ -428,6 +436,10 @@
return self.manifestProject.config.GetBoolean('repo.mirror')
@property
+ def UseGitWorktrees(self):
+ return self.manifestProject.config.GetBoolean('repo.worktree')
+
+ @property
def IsArchive(self):
return self.manifestProject.config.GetBoolean('repo.archive')
@@ -873,8 +885,10 @@
groups = self._ParseGroups(groups)
if parent is None:
- relpath, worktree, gitdir, objdir = self.GetProjectPaths(name, path)
+ relpath, worktree, gitdir, objdir, use_git_worktrees = \
+ self.GetProjectPaths(name, path)
else:
+ use_git_worktrees = False
relpath, worktree, gitdir, objdir = \
self.GetSubprojectPaths(parent, name, path)
@@ -903,6 +917,7 @@
upstream=upstream,
parent=parent,
dest_branch=dest_branch,
+ use_git_worktrees=use_git_worktrees,
**extra_proj_attrs)
for n in node.childNodes:
@@ -918,6 +933,7 @@
return project
def GetProjectPaths(self, name, path):
+ use_git_worktrees = False
relpath = path
if self.IsMirror:
worktree = None
@@ -926,8 +942,15 @@
else:
worktree = os.path.join(self.topdir, path).replace('\\', '/')
gitdir = os.path.join(self.repodir, 'projects', '%s.git' % path)
- objdir = os.path.join(self.repodir, 'project-objects', '%s.git' % name)
- return relpath, worktree, gitdir, objdir
+ # We allow people to mix git worktrees & non-git worktrees for now.
+ # This allows for in situ migration of repo clients.
+ if os.path.exists(gitdir) or not self.UseGitWorktrees:
+ objdir = os.path.join(self.repodir, 'project-objects', '%s.git' % name)
+ else:
+ use_git_worktrees = True
+ gitdir = os.path.join(self.repodir, 'worktrees', '%s.git' % name)
+ objdir = gitdir
+ return relpath, worktree, gitdir, objdir, use_git_worktrees
def GetProjectsWithName(self, name):
return self._projects.get(name, [])
diff --git a/project.py b/project.py
index 86c9ef0..3a7ac9e 100644
--- a/project.py
+++ b/project.py
@@ -866,6 +866,7 @@
clone_depth=None,
upstream=None,
parent=None,
+ use_git_worktrees=False,
is_derived=False,
dest_branch=None,
optimized_fetch=False,
@@ -889,6 +890,7 @@
sync_tags: The `sync-tags` attribute of manifest.xml's project element.
upstream: The `upstream` attribute of manifest.xml's project element.
parent: The parent Project object.
+ use_git_worktrees: Whether to use `git worktree` for this project.
is_derived: False if the project was explicitly defined in the manifest;
True if the project is a discovered submodule.
dest_branch: The branch to which to push changes for review by default.
@@ -923,6 +925,10 @@
self.clone_depth = clone_depth
self.upstream = upstream
self.parent = parent
+ # NB: Do not use this setting in __init__ to change behavior so that the
+ # manifest.git checkout can inspect & change it after instantiating. See
+ # the XmlManifest init code for more info.
+ self.use_git_worktrees = use_git_worktrees
self.is_derived = is_derived
self.optimized_fetch = optimized_fetch
self.subprojects = []
@@ -1872,15 +1878,19 @@
except KeyError:
head = None
if revid and head and revid == head:
- ref = os.path.join(self.gitdir, R_HEADS + name)
- try:
- os.makedirs(os.path.dirname(ref))
- except OSError:
- pass
- _lwrite(ref, '%s\n' % revid)
- _lwrite(self.GetHeadPath(), 'ref: %s%s\n' % (R_HEADS, name))
- branch.Save()
- return True
+ if self.use_git_worktrees:
+ self.work_git.update_ref(HEAD, revid)
+ branch.Save()
+ else:
+ ref = os.path.join(self.gitdir, R_HEADS + name)
+ try:
+ os.makedirs(os.path.dirname(ref))
+ except OSError:
+ pass
+ _lwrite(ref, '%s\n' % revid)
+ _lwrite(self.GetHeadPath(), 'ref: %s%s\n' % (R_HEADS, name))
+ branch.Save()
+ return True
if GitCommand(self,
['checkout', '-b', branch.name, revid],
@@ -2617,6 +2627,11 @@
os.makedirs(self.objdir)
self.bare_objdir.init()
+ # Enable per-worktree config file support if possible. This is more a
+ # nice-to-have feature for users rather than a hard requirement.
+ if self.use_git_worktrees and git_require((2, 19, 0)):
+ self.config.SetString('extensions.worktreeConfig', 'true')
+
# If we have a separate directory to hold refs, initialize it as well.
if self.objdir != self.gitdir:
if init_git_dir:
@@ -2651,13 +2666,15 @@
mirror_git = os.path.join(ref_dir, self.name + '.git')
repo_git = os.path.join(ref_dir, '.repo', 'projects',
self.relpath + '.git')
+ worktrees_git = os.path.join(ref_dir, '.repo', 'worktrees',
+ self.name + '.git')
if os.path.exists(mirror_git):
ref_dir = mirror_git
-
elif os.path.exists(repo_git):
ref_dir = repo_git
-
+ elif os.path.exists(worktrees_git):
+ ref_dir = worktrees_git
else:
ref_dir = None
@@ -2765,6 +2782,10 @@
self.bare_git.symbolic_ref('-m', msg, ref, dst)
def _CheckDirReference(self, srcdir, destdir, share_refs):
+ # Git worktrees don't use symlinks to share at all.
+ if self.use_git_worktrees:
+ return
+
symlink_files = self.shareable_files[:]
symlink_dirs = self.shareable_dirs[:]
if share_refs:
@@ -2864,11 +2885,38 @@
else:
raise
+ def _InitGitWorktree(self):
+ """Init the project using git worktrees."""
+ self.bare_git.worktree('prune')
+ self.bare_git.worktree('add', '-ff', '--checkout', '--detach', '--lock',
+ self.worktree, self.GetRevisionId())
+
+ # Rewrite the internal state files to use relative paths between the
+ # checkouts & worktrees.
+ dotgit = os.path.join(self.worktree, '.git')
+ with open(dotgit, 'r') as fp:
+ # Figure out the checkout->worktree path.
+ setting = fp.read()
+ assert setting.startswith('gitdir:')
+ git_worktree_path = setting.split(':', 1)[1].strip()
+ # Use relative path from checkout->worktree.
+ with open(dotgit, 'w') as fp:
+ print('gitdir:', os.path.relpath(git_worktree_path, self.worktree),
+ file=fp)
+ # Use relative path from worktree->checkout.
+ with open(os.path.join(git_worktree_path, 'gitdir'), 'w') as fp:
+ print(os.path.relpath(dotgit, git_worktree_path), file=fp)
+
def _InitWorkTree(self, force_sync=False, submodules=False):
realdotgit = os.path.join(self.worktree, '.git')
tmpdotgit = realdotgit + '.tmp'
init_dotgit = not os.path.exists(realdotgit)
if init_dotgit:
+ if self.use_git_worktrees:
+ self._InitGitWorktree()
+ self._CopyAndLinkFiles()
+ return
+
dotgit = tmpdotgit
platform_utils.rmtree(tmpdotgit, ignore_errors=True)
os.makedirs(tmpdotgit)
diff --git a/repo b/repo
index 77e7fe9..743c28b 100755
--- a/repo
+++ b/repo
@@ -302,6 +302,8 @@
group.add_option('--clone-filter', action='store', default='blob:none',
help='filter for use with --partial-clone '
'[default: %default]')
+ group.add_option('--worktree', action='store_true',
+ help=optparse.SUPPRESS_HELP)
group.add_option('--archive', action='store_true',
help='checkout an archive instead of a git repository for '
'each project. See git archive.')
diff --git a/subcmds/init.py b/subcmds/init.py
index 3c68c2c..8a29321 100644
--- a/subcmds/init.py
+++ b/subcmds/init.py
@@ -15,6 +15,8 @@
# limitations under the License.
from __future__ import print_function
+
+import optparse
import os
import platform
import re
@@ -128,6 +130,10 @@
g.add_option('--clone-filter', action='store', default='blob:none',
dest='clone_filter',
help='filter for use with --partial-clone [default: %default]')
+ # TODO(vapier): Expose option with real help text once this has been in the
+ # wild for a while w/out significant bug reports. Goal is by ~Sep 2020.
+ g.add_option('--worktree', action='store_true',
+ help=optparse.SUPPRESS_HELP)
g.add_option('--archive',
dest='archive', action='store_true',
help='checkout an archive instead of a git repository for '
@@ -246,6 +252,20 @@
if opt.dissociate:
m.config.SetString('repo.dissociate', 'true')
+ if opt.worktree:
+ if opt.mirror:
+ print('fatal: --mirror and --worktree are incompatible',
+ file=sys.stderr)
+ sys.exit(1)
+ if opt.submodules:
+ print('fatal: --submodules and --worktree are incompatible',
+ file=sys.stderr)
+ sys.exit(1)
+ m.config.SetString('repo.worktree', 'true')
+ if is_new:
+ m.use_git_worktrees = True
+ print('warning: --worktree is experimental!', file=sys.stderr)
+
if opt.archive:
if is_new:
m.config.SetString('repo.archive', 'true')
@@ -459,6 +479,10 @@
% ('.'.join(str(x) for x in MIN_GIT_VERSION_SOFT),),
file=sys.stderr)
+ if opt.worktree:
+ # Older versions of git supported worktree, but had dangerous gc bugs.
+ git_require((2, 15, 0), fail=True, msg='git gc worktree corruption')
+
self._SyncManifest(opt)
self._LinkManifest(opt.manifest_name)
diff --git a/subcmds/sync.py b/subcmds/sync.py
index 0ac308e..49867a9 100644
--- a/subcmds/sync.py
+++ b/subcmds/sync.py
@@ -15,6 +15,8 @@
# limitations under the License.
from __future__ import print_function
+
+import errno
import json
import netrc
from optparse import SUPPRESS_HELP
@@ -569,7 +571,8 @@
gc_gitdirs = {}
for project in projects:
# Make sure pruning never kicks in with shared projects.
- if len(project.manifest.GetProjectsWithName(project.name)) > 1:
+ if (not project.use_git_worktrees and
+ len(project.manifest.GetProjectsWithName(project.name)) > 1):
print('%s: Shared project %s found, disabling pruning.' %
(project.relpath, project.name))
if git_require((2, 7, 0)):
@@ -637,13 +640,22 @@
# Delete the .git directory first, so we're less likely to have a partially
# working git repository around. There shouldn't be any git projects here,
# so rmtree works.
+ dotgit = os.path.join(path, '.git')
+ # Try to remove plain files first in case of git worktrees. If this fails
+ # for any reason, we'll fall back to rmtree, and that'll display errors if
+ # it can't remove things either.
try:
- platform_utils.rmtree(os.path.join(path, '.git'))
+ platform_utils.remove(dotgit)
+ except OSError:
+ pass
+ try:
+ platform_utils.rmtree(dotgit)
except OSError as e:
- print('Failed to remove %s (%s)' % (os.path.join(path, '.git'), str(e)), file=sys.stderr)
- print('error: Failed to delete obsolete path %s' % path, file=sys.stderr)
- print(' remove manually, then run sync again', file=sys.stderr)
- return 1
+ if e.errno != errno.ENOENT:
+ print('error: %s: %s' % (dotgit, str(e)), file=sys.stderr)
+ print('error: %s: Failed to delete obsolete path; remove manually, then '
+ 'run sync again' % (path,), file=sys.stderr)
+ return 1
# Delete everything under the worktree, except for directories that contain
# another git project