Repository: psychopy/psychopy

Issue:
[Bug]: git clone fails if directory name is not specified.
### PsychoPy Version

2023.2.3

### What OS are your PsychoPy running on?

Windows 10

### Bug Description

When downloading a project from Pavlovia gitlab, if you choose a directory that is not empty, the following error occurs:

Traceback (most recent call last):
  File "C:\Program Files\PsychoPy\lib\site-packages\git\cmd.py", line 501, in wait
    raise GitCommandError(remove_password_if_present(self.args), status, errstr)
git.exc.GitCommandError: Cmd('C:\Program Files\PsychoPy\MinGit\cmd\git.exe') failed due to: exit code(128)
  cmdline: C:\Program Files\PsychoPy\MinGit\cmd\git.exe clone -v https://oauth2:*****@gitlab.pavlovia.org/jmmcbride/how_flat C:/Users/Administrator/Documents/PsychExp
  stderr: 'fatal: destination path 'C:/Users/Administrator/Documents/PsychExp' already exists and is not an empty directory.
'

### Expected Behaviour

To download the repository, I had to create a new empty folder, and choose that location for sync / download. This was not clear to me, and I had to figure it out through trial and error.

I suggest two fixes:

I would expect Psychopy to handle this error by cloning not to the specified {path} (in this case, C:/Users/Administrator/Documents/PsychExp), but to clone the respository to {path}/{respository_name} instead of {path}.

Otherwise, the error message could be updated to provide specific instructions for what to do.

### Steps to Reproduce

1.
2.
3.
...


### Additional context

_No response_


Discussion:

Resolving patch:
diff --git a/psychopy/projects/pavlovia.py b/psychopy/projects/pavlovia.py
--- a/psychopy/projects/pavlovia.py
+++ b/psychopy/projects/pavlovia.py
@@ -847,10 +847,12 @@ def sync(self, infoStream=None):
             dlg.ShowModal()
             return
         if self.project is not None:
-            # Reset local repo so it checks again (rather than erroring if it's been deleted without an app restart)
-            self._repo = None
             # Jot down start time
             t0 = time.time()
+            # make repo
+            repo = self.newRepo(infoStream)
+            if repo is None:
+                return 0
             # If first commit, do initial push
             if not bool(self.project.attributes['default_branch']):
                 self.firstPush(infoStream=infoStream)
@@ -1037,6 +1039,8 @@ def newRepo(self, infoStream=None):
                     bareRemote = True
                 else:
                     bareRemote = False
+
+        repo = None
         # if remote is new (or existed but is bare) then init and push
         if localFiles and bareRemote:  # existing folder
             repo = git.Repo.init(self.localRoot)
@@ -1048,10 +1052,35 @@ def newRepo(self, infoStream=None):
             self.stageFiles(['.gitignore'])
             self.commit('Create repository (including .gitignore)')
             self._newRemote = False
+        elif localFiles:
+            # get project name
+            if "/" in self.stringId:
+                _, projectName = self.stringId.split("/")
+            else:
+                projectName = self.stringId
+            # ask user if they want to clone to a subfolder
+            msg = _translate(
+                    "Folder '{localRoot}' is not empty, use '{localRoot}/{projectName}' instead?"
+            )
+            dlg = wx.MessageDialog(
+                None,
+                msg.format(localRoot=self.localRoot, projectName=projectName),
+                style=wx.ICON_QUESTION | wx.YES_NO | wx.CANCEL)
+            resp = dlg.ShowModal()
+            if resp == wx.ID_YES:
+                # if yes, update local root
+                self.localRoot = pathlib.Path(self.localRoot) / projectName
+                # try again
+                self.newRepo(infoStream=infoStream)
+            elif resp == wx.ID_CANCEL:
+                # if they cancelled, stop
+                infoStream.write(
+                    "Clone cancelled by user.\n"
+                )
+                repo = None
         else:
             # no files locally so safe to try and clone from remote
             repo = self.cloneRepo(infoStream=infoStream)
-            # TODO: add the further case where there are remote AND local files!
 
         return repo
 
@@ -1141,6 +1170,11 @@ def fork(self, to=None):
     def getChanges(self):
         """Find all the not-yet-committed changes in the repository"""
         changeDict = {}
+        changeList = []
+        # if we don't have a repo object, there's no changes
+        if not hasattr(self, "_repo") or self._repo is None:
+            return changeDict, changeList
+        # get changes
         changeDict['untracked'] = self.repo.untracked_files
         changeDict['changed'] = []
         changeDict['deleted'] = []
@@ -1162,7 +1196,6 @@ def getChanges(self):
                 changeDict['changed'].append(this.b_path)
             else:
                 raise ValueError("Found an unexpected change_type '{}' in gitpython Diff".format(this.change_type))
-        changeList = []
         for categ in changeDict:
             changeList.extend(changeDict[categ])
         return changeDict, changeList


Test patch: