From a74085afc1662f9eb77e9f559f425391f38e20aa Mon Sep 17 00:00:00 2001 From: MURAOKA Taro Date: Tue, 24 Feb 2026 21:20:04 +0900 Subject: [PATCH] osfs: Rename() to read-only file on Windows Unlike Linux, Windows os.Rename() fails if the destination file is read-only. Therefore, in this commit, we make the read-only file writable, then os.Rename() it, and restore the original permissions. This fixes a problem in go-git/go-git where Worktree.Add() always fails on repositories checked out with the git command on Windows because the object files are read-only. Signed-off-by: MURAOKA Taro --- osfs/os_chroot_test.go | 46 ++++++++++++++++++++++++++++++++++++++++++ osfs/os_windows.go | 31 +++++++++++++++++++++++++++- 2 files changed, 76 insertions(+), 1 deletion(-) diff --git a/osfs/os_chroot_test.go b/osfs/os_chroot_test.go index 712c5b3..aab2bd1 100644 --- a/osfs/os_chroot_test.go +++ b/osfs/os_chroot_test.go @@ -9,6 +9,7 @@ import ( "testing" "github.com/go-git/go-billy/v6" + "github.com/go-git/go-billy/v6/util" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -68,3 +69,48 @@ func TestCreateWithChroot(t *testing.T) { t, expected, actual, "Permission mismatch - expected: 0o%o, actual: 0o%o", expected, actual, ) } + +// Verify that the Rename() is successful even if the destination is a +// read-only file. +func TestRenameToReadonly(t *testing.T) { + fs, _ := setup(t) + chroot, _ := fs.Chroot("rename") + + // Prepare two files: rename source and destination + err := util.WriteFile(chroot, "src.txt", []byte("hello"), 0o644) + if err != nil { + t.Fatalf("failed to write src.txt: %s", err) + } + err = util.WriteFile(chroot, "dst.txt", []byte("world"), 0o444) + if err != nil { + t.Fatalf("failed to write dst.txt: %s", err) + } + + err = chroot.Rename("src.txt", "dst.txt") + if err != nil { + t.Fatalf("failed to rename to overwrite read-only file: %s", err) + } + + // src.txt must not exist + _, err = chroot.Stat("src.txt") + if err == nil { + t.Error("src.txt must not exist, but does it") + } else if !os.IsNotExist(err) { + t.Errorf("unexpected error on src.txt: %s", err) + } + + // Check dst.txt's permission and contents. + fi, err := chroot.Stat("dst.txt") + if err != nil { + t.Errorf("unexpected error on dst.txt: %s", err) + } + if perm := fi.Mode().Perm(); perm != 0o444 { + t.Errorf("unexpected permission of dst.txt: %04o", perm) + } + b, err := util.ReadFile(chroot, "dst.txt") + if err != nil { + t.Errorf("failed to read dst.txt: %s", err) + } else if string(b) != "hello" { + t.Errorf("unexpected contents of dst.txt: want=%q got=%q", "hello", string(b)) + } +} diff --git a/osfs/os_windows.go b/osfs/os_windows.go index af25ce9..0992c87 100644 --- a/osfs/os_windows.go +++ b/osfs/os_windows.go @@ -3,6 +3,7 @@ package osfs import ( + "io/fs" "os" "runtime" "unsafe" @@ -52,7 +53,35 @@ func (f *file) Sync() error { } func rename(from, to string) error { - return os.Rename(from, to) + // On Windows, os.Rename() fails when a read-only file is specified as the + // destination. (On Linux, for example, it succeeds even if the file is + // read-only if you have write permission in the parent directory.) + // Therefore, for read-only files, we must first change the permissions to + // allow writing, then rename them with os.Rename(), and then restore their + // original permissions. + var ( + modeChanged bool + originalMode fs.FileMode + ) + if fi, err := os.Stat(to); err == nil { + originalMode = fi.Mode() + if originalMode&0o200 == 0 { + err := os.Chmod(to, originalMode|0o200) + if err != nil { + return err + } + modeChanged = true + } + } + err := os.Rename(from, to) + if err != nil { + return err + } + // If we changed permissions, change them back + if modeChanged { + return os.Chmod(to, originalMode) + } + return nil } func umask(_ int) func() {