diff --git a/path.c b/path.c index d7e17bf17404de..67708ca94f67d9 100644 --- a/path.c +++ b/path.c @@ -18,6 +18,93 @@ #include "lockfile.h" #include "exec-cmd.h" +int translate_windows_path(struct strbuf *path) +{ +#ifdef GIT_WINDOWS_NATIVE + /* + * Windows-native: rewrite POSIX-mount paths (WSL `/mnt//`, + * Cygwin `/cygdrive//`, MSYS `//`) to drive form `:/...`. + * The single-letter + separator check below is what stops + * multi-character segments like `/mnt/storage` from matching. + */ + static const struct { + const char *prefix; + size_t prefix_len; + } posix_prefixes[] = { + { "/mnt/", 5 }, /* WSL */ + { "/cygdrive/", 10 }, /* Cygwin */ + { "/", 1 }, /* MSYS */ + }; + size_t i; + + if (path->len == 0) + return 0; + + for (i = 0; i < ARRAY_SIZE(posix_prefixes); i++) { + size_t pl = posix_prefixes[i].prefix_len; + char drive; + + if (path->len < pl + 1) + continue; + if (memcmp(path->buf, posix_prefixes[i].prefix, pl) != 0) + continue; + drive = path->buf[pl]; + if (!isalpha((unsigned char)drive)) + continue; + if (path->len > pl + 1 && path->buf[pl + 1] != '/' && path->buf[pl + 1] != '\\') + continue; + + /* "" (pl+1 bytes) -> ":" (2 bytes). */ + path->buf[0] = drive; + path->buf[1] = ':'; + memmove(path->buf + 2, path->buf + pl + 1, path->len - pl); + strbuf_setlen(path, path->len - (pl - 1)); + return 1; + } + return 0; +#else + /* + * POSIX: rewrite Windows-form `:/...` or `:\...` to this + * build's mount form (drive_prefix selected below), normalising + * any backslashes in the tail. + */ +#if defined(__MSYS__) + static const char drive_prefix[] = "/"; +#elif defined(__CYGWIN__) + static const char drive_prefix[] = "/cygdrive/"; +#else + static const char drive_prefix[] = "/mnt/"; +#endif + const size_t drive_prefix_len = sizeof(drive_prefix) - 1; + const size_t expansion = drive_prefix_len + 1 - 2; + char drive; + size_t i; + + if (path->len < 3) + return 0; + if (!isalpha((unsigned char)path->buf[0])) + return 0; + if (path->buf[1] != ':') + return 0; + if (path->buf[2] != '/' && path->buf[2] != '\\') + return 0; + + drive = tolower((unsigned char)path->buf[0]); + + strbuf_grow(path, expansion); + memmove(path->buf + 2 + expansion, path->buf + 2, path->len - 2 + 1); + memcpy(path->buf, drive_prefix, drive_prefix_len); + path->buf[drive_prefix_len] = drive; + path->len += expansion; + + for (i = drive_prefix_len + 1; i < path->len; i++) { + if (path->buf[i] == '\\') + path->buf[i] = '/'; + } + return 1; +#endif +} + static int get_st_mode_bits(const char *path, int *mode) { struct stat st; diff --git a/path.h b/path.h index 0434ba5e07e806..987651d1247026 100644 --- a/path.h +++ b/path.h @@ -6,6 +6,25 @@ struct strbuf; struct string_list; struct worktree; +/* + * Translate worktree gitdir paths between native Windows and POSIX-mount + * forms, in the direction appropriate for the current build: + * + * * Windows-native: `/mnt//`, `/cygdrive//`, or `//` -> `:/...` + * * MSYS: `:/` or `:\` -> `//...` + * * Cygwin: `:/` or `:\` -> `/cygdrive//...` + * * everything else (Linux/WSL/macOS/...): `:/` or `:\` -> `/mnt//...` + * + * `` must be a single ASCII letter; multi-character segments + * (`/mnt/storage`) and digit-prefixed mounts pass through unchanged. + * Backslashes in the tail are normalised to forward slashes on the + * POSIX-direction translation. + * + * Edits `path` in place; may shrink (Windows direction) or grow (POSIX + * direction). Returns 1 if a translation occurred, 0 otherwise. + */ +int translate_windows_path(struct strbuf *path); + /* * The result to all functions which return statically allocated memory may be * overwritten by another call to _any_ one of these functions. Consider using diff --git a/setup.c b/setup.c index 7ec4427368a2a7..60fe062931fd70 100644 --- a/setup.c +++ b/setup.c @@ -336,6 +336,7 @@ int get_common_dir_noenv(struct strbuf *sb, const char *gitdir) data.len--; data.buf[data.len] = '\0'; strbuf_reset(&path); + translate_windows_path(&data); if (!is_absolute_path(data.buf)) strbuf_addf(&path, "%s/", gitdir); strbuf_addbuf(&path, &data); @@ -1009,6 +1010,19 @@ const char *read_gitfile_gently(const char *path, int *return_error_code) buf[len] = '\0'; dir = buf + 8; + { + struct strbuf translated = STRBUF_INIT; + strbuf_addstr(&translated, dir); + if (translate_windows_path(&translated)) { + char *new_buf = xstrfmt("gitdir: %s", translated.buf); + free(buf); + buf = new_buf; + len = strlen(buf); + dir = buf + 8; + } + strbuf_release(&translated); + } + if (!is_absolute_path(dir) && (slash = strrchr(path, '/'))) { size_t pathlen = slash+1 - path; dir = xstrfmt("%.*s%.*s", (int)pathlen, path, diff --git a/t/helper/test-path-utils.c b/t/helper/test-path-utils.c index 874542ec3462a5..fd7ee7d81e3c84 100644 --- a/t/helper/test-path-utils.c +++ b/t/helper/test-path-utils.c @@ -401,6 +401,15 @@ int cmd__path_utils(int argc, const char **argv) return 0; } + if (argc == 3 && !strcmp(argv[1], "translate_windows_path")) { + struct strbuf sb = STRBUF_INIT; + strbuf_addstr(&sb, argv[2]); + translate_windows_path(&sb); + puts(sb.buf); + strbuf_release(&sb); + return 0; + } + if (argc == 4 && !strcmp(argv[1], "relative_path")) { struct strbuf sb = STRBUF_INIT; const char *in, *prefix, *rel; diff --git a/t/t0042-wsl-mnt-path.sh b/t/t0042-wsl-mnt-path.sh new file mode 100644 index 00000000000000..2ba403d8380726 --- /dev/null +++ b/t/t0042-wsl-mnt-path.sh @@ -0,0 +1,149 @@ +#!/bin/sh + +test_description='translate WSL/Cygwin /mnt// paths in worktree gitfiles + +Verify that `git worktree add` artefacts written from inside WSL2 or +Cygwin/MSYS - which use POSIX-mounted paths like `/mnt/c/...` or +`/cygdrive/c/...` - are still resolvable when read back from native +Windows git. +' + +GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME=main +export GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME + +. ./test-lib.sh + +# Convert any drive-prefixed path Windows git might emit to a POSIX-mount +# form rooted at $1. Handles MSYS form (/c/foo), Windows forward-slash +# form (C:/foo), and Windows backslash form (C:\foo). $1 may be empty, +# in which case the result is the MSYS2 mount form (/c/foo). MINGW-only. +mount_form () { + prefix=$1 + path=$2 + case "$path" in + /[A-Za-z]/*) + echo "$path" | sed -E "s|^/([A-Za-z])/|$prefix/\\L\\1/|" + ;; + [A-Za-z]:/*) + echo "$path" | sed -E "s|^([A-Za-z]):/|$prefix/\\L\\1/|" + ;; + [A-Za-z]:'\'*) + echo "$path" | sed -E "s|^([A-Za-z]):.|$prefix/\\L\\1/|; s|\\\\|/|g" + ;; + *) + echo "$path" + ;; + esac +} + +to_mnt () { mount_form /mnt "$1"; } +to_cygdrive () { mount_form /cygdrive "$1"; } +to_msys () { mount_form "" "$1"; } + +test_expect_success MINGW 'setup main repo' ' + git init repo && + test_commit -C repo init +' + +test_expect_success MINGW 'read_gitfile_gently translates /mnt// gitdir' ' + test_when_finished "rm -rf wtlink actual" && + REAL=$(cd repo/.git && pwd) && + MNT=$(to_mnt "$REAL") && + + # Sanity: the path must actually start with /mnt/ - if it does not, + # the host shell did not give us a path with a drive prefix and the + # rest of the test would be silently meaningless. + case "$MNT" in + /mnt/*) : ok ;; + *) BUG "to_mnt produced $MNT from $REAL" ;; + esac && + + mkdir wtlink && + printf "gitdir: %s\n" "$MNT" >wtlink/.git && + + (cd wtlink && git rev-parse --git-dir) >actual && + test_path_is_dir "$(cat actual)" +' + +test_expect_success MINGW 'read_gitfile_gently translates /cygdrive// gitdir' ' + test_when_finished "rm -rf wtlink actual" && + REAL=$(cd repo/.git && pwd) && + CYG=$(to_cygdrive "$REAL") && + + mkdir wtlink && + printf "gitdir: %s\n" "$CYG" >wtlink/.git && + + (cd wtlink && git rev-parse --git-dir) >actual && + test_path_is_dir "$(cat actual)" +' + +test_expect_success MINGW 'read_gitfile_gently translates // MSYS2 gitdir' ' + test_when_finished "rm -rf wtlink actual" && + REAL=$(cd repo/.git && pwd) && + MSYS_PATH=$(to_msys "$REAL") && + + case "$MSYS_PATH" in + /[A-Za-z]/*) : ok ;; + *) BUG "to_msys produced $MSYS_PATH from $REAL" ;; + esac && + + mkdir wtlink && + printf "gitdir: %s\n" "$MSYS_PATH" >wtlink/.git && + + (cd wtlink && git rev-parse --git-dir) >actual && + test_path_is_dir "$(cat actual)" +' + +test_expect_success MINGW 'read_gitfile_gently leaves /mnt// alone' ' + test_when_finished "rm -rf wtlink" && + mkdir wtlink && + # "storage" is not a single drive letter, so this must not be + # translated. The path does not exist on Windows, so the open fails. + echo "gitdir: /mnt/storage/no/such/repo" >wtlink/.git && + + test_must_fail git -C wtlink rev-parse --git-dir 2>err && + test_grep "not a git repository" err +' + +test_expect_success MINGW 'get_linked_worktree finds worktree recorded with /mnt// path' ' + test_when_finished "rm -rf repo/wt repo/.git/worktrees/wt" && + + git -C repo worktree add --detach wt && + WT_REAL=$(cd repo/wt && pwd) && + WT_MNT=$(to_mnt "$WT_REAL") && + + # Overwrite the recorded worktree path with the WSL form, mimicking + # what `git worktree add` writes when run from inside WSL. + printf "%s/.git\n" "$WT_MNT" >repo/.git/worktrees/wt/gitdir && + + # `git worktree list` reads that file via get_linked_worktree. + # After translation the worktree must still be reachable: it must + # NOT be flagged prunable, and a git operation inside the worktree + # directory must succeed. + git -C repo worktree list --porcelain >list && + ! grep -q "^prunable" list && + (cd "$WT_REAL" && git rev-parse --is-inside-work-tree) +' + +test_expect_success MINGW 'get_common_dir_noenv translates /mnt// commondir' ' + test_when_finished "rm -rf wtdir wt actual" && + + REAL=$(cd repo/.git && pwd) && + MNT=$(to_mnt "$REAL") && + + # Build a synthetic linked-worktree gitdir that points at the main + # repo via a /mnt// commondir record. + mkdir wtdir && + echo "$(cd repo && git rev-parse HEAD)" >wtdir/HEAD && + echo "$MNT" >wtdir/commondir && + printf "%s/.git\n" "$(pwd)" >wtdir/gitdir && + + # rev-parse --git-common-dir on a checkout that points here should + # resolve through the translated commondir. + mkdir wt && + printf "gitdir: %s\n" "$(pwd)/wtdir" >wt/.git && + (cd wt && git rev-parse --git-common-dir) >actual && + test_path_is_dir "$(cat actual)" +' + +test_done diff --git a/t/t0060-path-utils.sh b/t/t0060-path-utils.sh index 8545cdfab559b4..016c330badbd9c 100755 --- a/t/t0060-path-utils.sh +++ b/t/t0060-path-utils.sh @@ -611,4 +611,32 @@ test_expect_success !VALGRIND,RUNTIME_PREFIX,CAN_EXEC_IN_PWD '%(prefix)/ works' test_cmp expect actual ' +case "$(uname -s)" in +*CYGWIN*) MOUNT_PREFIX=/cygdrive ;; +*MSYS_NT*) MOUNT_PREFIX= ;; +*) MOUNT_PREFIX=/mnt ;; +esac + +translate_windows_path() { + test_expect_success !MINGW "translate_windows_path: $1 => $2" " + echo '$2' >expect && + test-tool path-utils translate_windows_path '$1' >actual && + test_cmp expect actual + " +} + +translate_windows_path 'C:/foo/bar' "$MOUNT_PREFIX/c/foo/bar" +translate_windows_path 'C:\foo\bar' "$MOUNT_PREFIX/c/foo/bar" +translate_windows_path 'D:/repo/.git/worktrees/wt' "$MOUNT_PREFIX/d/repo/.git/worktrees/wt" +translate_windows_path 'Z:\path\with mixed/seps' "$MOUNT_PREFIX/z/path/with mixed/seps" +translate_windows_path 'c:/already-lower' "$MOUNT_PREFIX/c/already-lower" + +# Inputs that must NOT be translated: +translate_windows_path '/already/posix' '/already/posix' +translate_windows_path 'relative/path' 'relative/path' +translate_windows_path 'C:relative-no-separator' 'C:relative-no-separator' +translate_windows_path '1:/digit-prefix' '1:/digit-prefix' +translate_windows_path 'C' 'C' +translate_windows_path '' '' + test_done diff --git a/worktree.c b/worktree.c index d874e23b4e158a..c44ee6dd91235c 100644 --- a/worktree.c +++ b/worktree.c @@ -155,6 +155,9 @@ struct worktree *get_linked_worktree(const char *id, strbuf_rtrim(&worktree_path); strbuf_strip_suffix(&worktree_path, "/.git"); + /* Convert Windows path to POSIX path or vice-versa. */ + translate_windows_path(&worktree_path); + if (!is_absolute_path(worktree_path.buf)) { strbuf_strip_suffix(&path, "gitdir"); strbuf_addbuf(&path, &worktree_path); @@ -992,6 +995,18 @@ int should_prune_worktree(const char *id, struct strbuf *reason, char **wtpath, goto done; } path[len] = '\0'; + + { + struct strbuf translated = STRBUF_INIT; + strbuf_addstr(&translated, path); + if (translate_windows_path(&translated)) { + free(path); + path = strbuf_detach(&translated, NULL); + } else { + strbuf_release(&translated); + } + } + if (is_absolute_path(path)) { strbuf_addstr(&dotgit, path); } else {