| 1 | package gitcmd |
| 2 | |
| 3 | import ( |
| 4 | "errors" |
| 5 | "os" |
| 6 | "path/filepath" |
| 7 | "strings" |
| 8 | "testing" |
| 9 | ) |
| 10 | |
| 11 | // A Repo is resolved when the session opens; what the workspace gains |
| 12 | // afterwards must not change which repository, or whose config, host git reads. |
| 13 | |
| 14 | func (f *repoFixture) open(dir string) Repo { |
| 15 | f.t.Helper() |
| 16 | repo, err := Open(f.ctx, dir) |
| 17 | if err != nil { |
| 18 | f.t.Fatalf("Open(%s): %v", dir, err) |
| 19 | } |
| 20 | return repo |
| 21 | } |
| 22 | |
| 23 | func (f *repoFixture) repoOut(repo Repo, args ...string) (string, error) { |
| 24 | f.t.Helper() |
| 25 | out, err := repo.Command(f.ctx, args...).Output() |
| 26 | return strings.TrimSpace(string(out)), err |
| 27 | } |
| 28 | |
| 29 | // substituteConfig is a local config a later write points git at: a probe key |
| 30 | // to read the config's origin by, and a filter that records any run. |
| 31 | func (f *repoFixture) substituteConfig() string { |
| 32 | return "[probe]\n\tx = substitute\n[filter \"evil\"]\n\tclean = " + f.payload() + "\n\tsmudge = " + f.payload() + "\n" |
| 33 | } |
| 34 | |
| 35 | func (f *repoFixture) assertReadsOpenedRepository(repo Repo, wantGitDir string) { |
| 36 | f.t.Helper() |
| 37 | if got, err := f.repoOut(repo, "config", "--get", "probe.x"); err == nil || got != "" { |
| 38 | f.t.Fatalf("config probe.x = %q (err %v), want the substitute config unread", got, err) |
| 39 | } |
| 40 | if got, err := f.repoOut(repo, "rev-parse", "--absolute-git-dir"); err != nil || filepath.Clean(got) != wantGitDir { |
| 41 | f.t.Fatalf("git dir = %q (err %v), want %s", got, err, wantGitDir) |
| 42 | } |
| 43 | f.makeStatDirty("f.txt", "two\n") |
| 44 | if _, err := f.repoOut(repo, "status", "--porcelain"); err != nil { |
| 45 | f.t.Fatalf("status: %v", err) |
| 46 | } |
| 47 | if _, err := f.repoOut(repo, "diff", "HEAD"); err != nil { |
| 48 | f.t.Fatalf("diff: %v", err) |
| 49 | } |
| 50 | f.assertNotExecuted() |
| 51 | } |
| 52 | |
| 53 | func TestOpenResolvesLinkedWorktreeIdentity(t *testing.T) { |
| 54 | f := newRepoFixture(t, "", map[string]string{"f.txt": "one\n"}) |
| 55 | linked := filepath.Join(t.TempDir(), "linked") |
| 56 | f.plain("worktree", "add", "--quiet", linked) |
| 57 | repo := f.open(linked) |
| 58 | common := f.open(f.dir).GitDir |
| 59 | real, err := filepath.EvalSymlinks(linked) |
| 60 | if err != nil { |
| 61 | t.Fatal(err) |
| 62 | } |
| 63 | if repo.CommonDir != common || repo.GitDir == common || repo.WorkTree != real { |
| 64 | t.Fatalf("linked worktree resolved as %+v, want common dir %s", repo, common) |
| 65 | } |
| 66 | if _, err := Open(f.ctx, t.TempDir()); !errors.Is(err, ErrNotRepository) { |
| 67 | t.Fatalf("Open(non-repo) = %v, want ErrNotRepository", err) |
| 68 | } |
| 69 | if err := (Repo{}).Command(f.ctx, "status").Run(); !errors.Is(err, ErrNotRepository) { |
| 70 | t.Fatalf("unresolved Repo ran: %v, want ErrNotRepository", err) |
| 71 | } |
| 72 | } |
| 73 | |
| 74 | // A workspace that is a subdirectory of a repository gains its own .git. |
| 75 | func TestOpenedRepoIgnoresNestedRepositoryCreatedLater(t *testing.T) { |
| 76 | f := newRepoFixture(t, "", map[string]string{"pkg/f.txt": "one\n", "f.txt": "one\n"}) |
| 77 | pkg := filepath.Join(f.dir, "pkg") |
| 78 | repo := f.open(pkg) |
| 79 | if top := f.open(f.dir); repo.GitDir != top.GitDir || repo.WorkTree != top.WorkTree { |
| 80 | t.Fatalf("subdirectory resolved as %+v, want the enclosing repository %+v", repo, top) |
| 81 | } |
| 82 | f.plainIn(pkg, "init", "--quiet") |
| 83 | if err := os.WriteFile(filepath.Join(pkg, ".gitattributes"), []byte("*.txt filter=evil\n"), 0o644); err != nil { |
| 84 | t.Fatal(err) |
| 85 | } |
| 86 | if err := os.WriteFile(filepath.Join(pkg, ".git", "config"), []byte(f.substituteConfig()), 0o644); err != nil { |
| 87 | t.Fatal(err) |
| 88 | } |
| 89 | if out, _ := Command(f.ctx, pkg, "config", "--get", "probe.x").Output(); strings.TrimSpace(string(out)) != "substitute" { |
| 90 | t.Fatalf("discovery from the workspace read %q, want the nested config (the case being guarded)", out) |
| 91 | } |
| 92 | f.assertReadsOpenedRepository(repo, repo.GitDir) |
| 93 | } |
| 94 | |
| 95 | // A commondir file written into the git dir redirects discovery to another |
| 96 | // repository's config. |
| 97 | func TestOpenedRepoIgnoresCommondirWrittenLater(t *testing.T) { |
| 98 | f := newRepoFixture(t, "", map[string]string{"f.txt": "one\n"}) |
| 99 | repo := f.open(f.dir) |
| 100 | evil := filepath.Join(t.TempDir(), "evil") |
| 101 | f.plain("clone", "--quiet", "--bare", f.dir, evil) |
| 102 | if err := os.WriteFile(filepath.Join(evil, "config"), []byte("[core]\n\trepositoryformatversion = 0\n"+f.substituteConfig()), 0o644); err != nil { |
| 103 | t.Fatal(err) |
| 104 | } |
| 105 | f.write(".gitattributes", "*.txt filter=evil\n") |
| 106 | if err := os.WriteFile(filepath.Join(f.dir, ".git", "commondir"), []byte(evil+"\n"), 0o644); err != nil { |
| 107 | t.Fatal(err) |
| 108 | } |
| 109 | if out, _ := Command(f.ctx, f.dir, "config", "--get", "probe.x").Output(); strings.TrimSpace(string(out)) != "substitute" { |
| 110 | t.Fatalf("discovery read %q, want the commondir's config (the case being guarded)", out) |
| 111 | } |
| 112 | f.assertReadsOpenedRepository(repo, repo.GitDir) |
| 113 | } |
| 114 | |
| 115 | // A damaged HEAD makes the git dir invalid; the workspace root dressed as an |
| 116 | // implicit bare repository must not stand in for it. |
| 117 | func TestOpenedRepoFailsClosedOnDamagedHead(t *testing.T) { |
| 118 | f := newRepoFixture(t, "", map[string]string{"f.txt": "one\n"}) |
| 119 | repo := f.open(f.dir) |
| 120 | for _, d := range []string{"objects", "refs"} { |
| 121 | if err := os.MkdirAll(filepath.Join(f.dir, d), 0o755); err != nil { |
| 122 | t.Fatal(err) |
| 123 | } |
| 124 | } |
| 125 | f.write("HEAD", "ref: refs/heads/main\n") |
| 126 | f.write("config", "[core]\n\tbare = false\n\tworktree = "+f.dir+"\n"+f.substituteConfig()) |
| 127 | f.write(".gitattributes", "*.txt filter=evil\n") |
| 128 | if err := os.WriteFile(filepath.Join(f.dir, ".git", "HEAD"), []byte("garbage\n"), 0o644); err != nil { |
| 129 | t.Fatal(err) |
| 130 | } |
| 131 | f.makeStatDirty("f.txt", "two\n") |
| 132 | for _, args := range [][]string{{"config", "--get", "probe.x"}, {"status", "--porcelain"}, {"diff", "HEAD"}} { |
| 133 | if out, err := f.repoOut(repo, args...); err == nil || strings.Contains(out, "substitute") { |
| 134 | t.Fatalf("%v = %q (err %v), want a failure naming no substitute", args, out, err) |
| 135 | } |
| 136 | if out, _ := f.run(args...); strings.Contains(out, "substitute") { |
| 137 | t.Fatalf("discovery %v read the implicit bare repository: %q", args, out) |
| 138 | } |
| 139 | } |
| 140 | f.assertNotExecuted() |
| 141 | } |
| 142 | |
| 143 | // A git dir that belongs to another checkout makes host diffs read, and host |
| 144 | // writes land in, that checkout: Open refuses it as not a repository. |
| 145 | func TestOpenRefusesGitDirOfAnotherCheckout(t *testing.T) { |
| 146 | other := newRepoFixture(t, "", map[string]string{"other.txt": "other\n"}) |
| 147 | t.Run("gitdir file", func(t *testing.T) { |
| 148 | ws := t.TempDir() |
| 149 | if err := os.WriteFile(filepath.Join(ws, ".git"), []byte("gitdir: "+filepath.Join(other.dir, ".git")+"\n"), 0o644); err != nil { |
| 150 | t.Fatal(err) |
| 151 | } |
| 152 | if out, _ := Command(other.ctx, ws, "ls-files").Output(); !strings.Contains(string(out), "other.txt") { |
| 153 | t.Fatalf("discovery listed %q, want the other checkout (the case being guarded)", out) |
| 154 | } |
| 155 | if _, err := Open(other.ctx, ws); !errors.Is(err, ErrNotRepository) { |
| 156 | t.Fatalf("Open = %v, want ErrNotRepository", err) |
| 157 | } |
| 158 | }) |
| 159 | t.Run("core.worktree", func(t *testing.T) { |
| 160 | f := newRepoFixture(t, "", map[string]string{"f.txt": "one\n"}) |
| 161 | f.plain("config", "core.worktree", other.dir) |
| 162 | if _, err := Open(f.ctx, f.dir); !errors.Is(err, ErrNotRepository) { |
| 163 | t.Fatalf("Open = %v, want ErrNotRepository", err) |
| 164 | } |
| 165 | }) |
| 166 | t.Run("linked admin dir of another worktree", func(t *testing.T) { |
| 167 | linked := filepath.Join(t.TempDir(), "linked") |
| 168 | other.plain("worktree", "add", "--quiet", linked) |
| 169 | ws := t.TempDir() |
| 170 | admin := strings.TrimSpace(other.plainIn(linked, "rev-parse", "--absolute-git-dir")) |
| 171 | if err := os.WriteFile(filepath.Join(ws, ".git"), []byte("gitdir: "+admin+"\n"), 0o644); err != nil { |
| 172 | t.Fatal(err) |
| 173 | } |
| 174 | if _, err := Open(other.ctx, ws); !errors.Is(err, ErrNotRepository) { |
| 175 | t.Fatalf("Open = %v, want ErrNotRepository", err) |
| 176 | } |
| 177 | }) |
| 178 | } |
| 179 | |
| 180 | // A stored identity is held to Open's test when it is used again. |
| 181 | func TestVerifyRefusesStoredIdentityOfAnotherCheckout(t *testing.T) { |
| 182 | f := newRepoFixture(t, "", map[string]string{"f.txt": "one\n"}) |
| 183 | other := newRepoFixture(t, "", map[string]string{"other.txt": "other\n"}) |
| 184 | repo := f.open(f.dir) |
| 185 | if err := repo.Verify(); err != nil { |
| 186 | t.Fatalf("Verify(opened) = %v", err) |
| 187 | } |
| 188 | forged := repo |
| 189 | forged.GitDir = other.open(other.dir).GitDir |
| 190 | forged.CommonDir = forged.GitDir |
| 191 | if err := forged.Verify(); !errors.Is(err, ErrNotRepository) { |
| 192 | t.Fatalf("Verify(forged) = %v, want ErrNotRepository", err) |
| 193 | } |
| 194 | if err := (Repo{}).Verify(); !errors.Is(err, ErrNotRepository) { |
| 195 | t.Fatalf("Verify(zero) = %v, want ErrNotRepository", err) |
| 196 | } |
| 197 | } |
| 198 |