Repository: jesseduffield/lazygit (language: go)

Issue:
Exclude file does not work inside submodules
**Describe the bug**

Appending the file name to the `.git/info/exclude` file does not work because `.git` might not be a directory. 

**To Reproduce**
Steps to reproduce the behavior:

1. Clone a repository with a submodule
2. Open submodule in `lazygit`
3. Try to exclude an untracked file by pressing `i` then `e`
4. See error `open .git/info/exclude: not a directory`

**Expected behavior**
File gets added to `exclude` list and gets ignored by Git.

**Screenshots**
If applicable, add screenshots to help explain your problem.

**Version info:**
```
commit=fb675b79f8a4a949294e8cab85ce72fed3883362, build date=2024-03-23T22:49:22Z, build source=unknown, version=fb675b79, os=linux, arch=amd64, git version=2.34.1
```

```
git version 2.34.1
```

**Additional context**

This most likely happens because `.git` is just a file that points to the actual `.git` directory when inside a submodule like so:

```
gitdir: ../../.git/modules/<SUBMODULE_NAME>
```

This can be solved by first checking if `.git` is a directory and resolving to the repo's actual `exclude` file.

```diff
diff --git a/pkg/commands/git_commands/working_tree.go b/pkg/commands/git_commands/working_tree.go
index 99665d7c..a5fa5993 100644
--- a/pkg/commands/git_commands/working_tree.go
+++ b/pkg/commands/git_commands/working_tree.go
@@ -3,6 +3,8 @@ package git_commands
 import (
 	"fmt"
 	"os"
+	"path/filepath"
+	"strings"
 
 	"github.com/go-errors/errors"
 	"github.com/jesseduffield/lazygit/pkg/commands/models"
@@ -232,7 +234,27 @@ func (self *WorkingTreeCommands) Ignore(filename string) error {
 
 // Exclude adds a file to the .git/info/exclude for the repo
 func (self *WorkingTreeCommands) Exclude(filename string) error {
-	return self.os.AppendLineToFile(".git/info/exclude", filename)
+	// .git might not be a directory if repo is a submodule
+	gitDir := ".git"
+	fileInfo, err := os.Stat(gitDir)
+	if err != nil {
+		return err
+	}
+
+	if !fileInfo.IsDir() {
+		// Submodule, try to resolve gitdir
+		file, err := os.ReadFile(gitDir)
+		if err != nil {
+			return err
+		}
+		_, actualDirPath, ok := strings.Cut(string(file), "gitdir: ")
+		if !ok {
+			return fmt.Errorf("invalid gitdir file")
+		}
+		gitDir = actualDirPath
+	}
+
+	return self.os.AppendLineToFile(filepath.Join(gitDir, "info/exclude"), filename)
 }
 
 // WorktreeFileDiff returns the diff of a file
```

**Note:** please try updating to the latest version or [manually building](https://github.com/jesseduffield/lazygit/#manual) the latest `master` to see if the issue still occurs.

<!--
If you want to try and debug this issue yourself, you can run `lazygit --debug` in one terminal panel and `lazygit --logs` in another to view the logs.
-->



Discussion / hints:

Fix patch:
diff --git a/pkg/commands/git_commands/working_tree.go b/pkg/commands/git_commands/working_tree.go
index 99665d7cca2..c3338650a94 100644
--- a/pkg/commands/git_commands/working_tree.go
+++ b/pkg/commands/git_commands/working_tree.go
@@ -3,6 +3,7 @@ package git_commands
 import (
 	"fmt"
 	"os"
+	"path"
 
 	"github.com/go-errors/errors"
 	"github.com/jesseduffield/lazygit/pkg/commands/models"
@@ -232,7 +233,8 @@ func (self *WorkingTreeCommands) Ignore(filename string) error {
 
 // Exclude adds a file to the .git/info/exclude for the repo
 func (self *WorkingTreeCommands) Exclude(filename string) error {
-	return self.os.AppendLineToFile(".git/info/exclude", filename)
+	excludeFile := path.Join(self.repoPaths.repoGitDirPath, "info", "exclude")
+	return self.os.AppendLineToFile(excludeFile, filename)
 }
 
 // WorktreeFileDiff returns the diff of a file
diff --git a/pkg/gui/controllers/files_controller.go b/pkg/gui/controllers/files_controller.go
index 313c23b4212..25d41cd6fc7 100644
--- a/pkg/gui/controllers/files_controller.go
+++ b/pkg/gui/controllers/files_controller.go
@@ -608,28 +608,15 @@ func (self *FilesController) ignore(node *filetree.FileNode) error {
 	if node.GetPath() == ".gitignore" {
 		return self.c.ErrorMsg(self.c.Tr.Actions.IgnoreFileErr)
 	}
-	err := self.ignoreOrExcludeFile(node, self.c.Tr.IgnoreTracked, self.c.Tr.IgnoreTrackedPrompt, self.c.Tr.Actions.IgnoreExcludeFile, self.c.Git().WorkingTree.Ignore)
-	if err != nil {
-		return err
-	}
-
-	return nil
+	return self.ignoreOrExcludeFile(node, self.c.Tr.IgnoreTracked, self.c.Tr.IgnoreTrackedPrompt, self.c.Tr.Actions.IgnoreExcludeFile, self.c.Git().WorkingTree.Ignore)
 }
 
 func (self *FilesController) exclude(node *filetree.FileNode) error {
-	if node.GetPath() == ".git/info/exclude" {
-		return self.c.ErrorMsg(self.c.Tr.Actions.ExcludeFileErr)
-	}
-
 	if node.GetPath() == ".gitignore" {
 		return self.c.ErrorMsg(self.c.Tr.Actions.ExcludeGitIgnoreErr)
 	}
 
-	err := self.ignoreOrExcludeFile(node, self.c.Tr.ExcludeTracked, self.c.Tr.ExcludeTrackedPrompt, self.c.Tr.Actions.ExcludeFile, self.c.Git().WorkingTree.Exclude)
-	if err != nil {
-		return err
-	}
-	return nil
+	return self.ignoreOrExcludeFile(node, self.c.Tr.ExcludeTracked, self.c.Tr.ExcludeTrackedPrompt, self.c.Tr.Actions.ExcludeFile, self.c.Git().WorkingTree.Exclude)
 }
 
 func (self *FilesController) ignoreOrExcludeMenu(node *filetree.FileNode) error {
diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go
index a31c8ceeea1..77e2432ad62 100644
--- a/pkg/i18n/english.go
+++ b/pkg/i18n/english.go
@@ -873,7 +873,6 @@ type Actions struct {
 	IgnoreExcludeFile                 string
 	IgnoreFileErr                     string
 	ExcludeFile                       string
-	ExcludeFileErr                    string
 	ExcludeGitIgnoreErr               string
 	Commit                            string
 	EditFile                          string
@@ -1796,7 +1795,6 @@ func EnglishTranslationSet() TranslationSet {
 			IgnoreExcludeFile:               "Ignore or exclude file",
 			IgnoreFileErr:                   "Cannot ignore .gitignore",
 			ExcludeFile:                     "Exclude file",
-			ExcludeFileErr:                  "Cannot exclude .git/info/exclude",
 			ExcludeGitIgnoreErr:             "Cannot exclude .gitignore",
 			Commit:                          "Commit",
 			EditFile:                        "Edit file",
diff --git a/pkg/i18n/polish.go b/pkg/i18n/polish.go
index 3d3f26e0686..a24d44ca236 100644
--- a/pkg/i18n/polish.go
+++ b/pkg/i18n/polish.go
@@ -807,7 +807,6 @@ func polishTranslationSet() TranslationSet {
 			IgnoreExcludeFile:               "Ignoruj lub wyklucz plik",
 			IgnoreFileErr:                   "Nie można zignorować .gitignore",
 			ExcludeFile:                     "Wyklucz plik",
-			ExcludeFileErr:                  "Nie można wykluczyć .git/info/exclude",
 			ExcludeGitIgnoreErr:             "Nie można wykluczyć .gitignore",
 			Commit:                          "Commituj",
 			EditFile:                        "Edytuj plik",
diff --git a/pkg/i18n/russian.go b/pkg/i18n/russian.go
index 0b4f2e61812..5378d774e92 100644
--- a/pkg/i18n/russian.go
+++ b/pkg/i18n/russian.go
@@ -599,7 +599,6 @@ func RussianTranslationSet() TranslationSet {
 			IgnoreExcludeFile:                 "Игнорировать или исключить файл",
 			IgnoreFileErr:                     "Невозможно игнорировать .gitignore",
 			ExcludeFile:                       "Исключить файл",
-			ExcludeFileErr:                    "Невозможно исключить .git/info/exclude",
 			ExcludeGitIgnoreErr:               "Невозможно исключить .gitignore",
 			Commit:                            "Коммит",
 			EditFile:                          "Редактировать файл",
diff --git a/pkg/i18n/traditional_chinese.go b/pkg/i18n/traditional_chinese.go
index 54d283f00e1..56d2c99a0b3 100644
--- a/pkg/i18n/traditional_chinese.go
+++ b/pkg/i18n/traditional_chinese.go
@@ -667,7 +667,6 @@ func traditionalChineseTranslationSet() TranslationSet {
 			IgnoreExcludeFile:                 "忽略或排除檔案",
 			IgnoreFileErr:                     "無法忽略 .gitignore 檔案",
 			ExcludeFile:                       "排除檔案",
-			ExcludeFileErr:                    "無法排除 .git/info/exclude 檔案",
 			ExcludeGitIgnoreErr:               "無法排除 .gitignore 檔案",
 			Commit:                            "提交",
 			EditFile:                          "編輯檔案",


Test patch:
diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go
index c5a5b64d629..8f62ca7f5ee 100644
--- a/pkg/integration/tests/test_list.go
+++ b/pkg/integration/tests/test_list.go
@@ -308,6 +308,7 @@ var tests = []*components.IntegrationTest{
 	worktree.DetachWorktreeFromBranch,
 	worktree.DotfileBareRepo,
 	worktree.DoubleNestedLinkedSubmodule,
+	worktree.ExcludeFileInWorktree,
 	worktree.FastForwardWorktreeBranch,
 	worktree.ForceRemoveWorktree,
 	worktree.RemoveWorktreeFromBranch,
diff --git a/pkg/integration/tests/worktree/exclude_file_in_worktree.go b/pkg/integration/tests/worktree/exclude_file_in_worktree.go
new file mode 100644
index 00000000000..ba3f9e5a5f4
--- /dev/null
+++ b/pkg/integration/tests/worktree/exclude_file_in_worktree.go
@@ -0,0 +1,41 @@
+package worktree
+
+import (
+	"github.com/jesseduffield/lazygit/pkg/config"
+	. "github.com/jesseduffield/lazygit/pkg/integration/components"
+)
+
+var ExcludeFileInWorktree = NewIntegrationTest(NewIntegrationTestArgs{
+	Description:  "Add a file to .git/info/exclude in a worktree",
+	ExtraCmdArgs: []string{},
+	Skip:         false,
+	SetupConfig:  func(config *config.AppConfig) {},
+	SetupRepo: func(shell *Shell) {
+		shell.EmptyCommit("commit1")
+		shell.AddWorktree("HEAD", "../linked-worktree", "mybranch")
+		shell.CreateFile("../linked-worktree/toExclude", "")
+	},
+	Run: func(t *TestDriver, keys config.KeybindingConfig) {
+		t.Views().Worktrees().
+			Focus().
+			Lines(
+				Contains("repo (main)").IsSelected(),
+				Contains("linked-worktree"),
+			).
+			SelectNextItem().
+			PressPrimaryAction()
+
+		t.Views().Files().
+			Focus().
+			Lines(
+				Contains("toExclude"),
+			).
+			Press(keys.Files.IgnoreFile).
+			Tap(func() {
+				t.ExpectPopup().Menu().Title(Equals("Ignore or exclude file")).Select(Contains("Add to .git/info/exclude")).Confirm()
+			}).
+			IsEmpty()
+
+		t.FileSystem().FileContent("../repo/.git/info/exclude", Contains("toExclude"))
+	},
+})