summaryrefslogtreecommitdiff
path: root/plugins/git4idea/src/git4idea/repo/GitRepositoryReader.java
blob: 576f326a8ea3939148267ef146135378a4e638fd (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
/*
 * Copyright 2000-2013 JetBrains s.r.o.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package git4idea.repo;

import com.intellij.dvcs.repo.RepoStateException;
import com.intellij.dvcs.repo.Repository;
import com.intellij.dvcs.repo.RepositoryUtil;
import com.intellij.openapi.diagnostic.Logger;
import com.intellij.openapi.util.Condition;
import com.intellij.openapi.util.io.FileUtil;
import com.intellij.util.Processor;
import com.intellij.util.containers.ContainerUtil;
import com.intellij.vcs.log.Hash;
import com.intellij.vcs.log.impl.HashImpl;
import git4idea.GitBranch;
import git4idea.GitLocalBranch;
import git4idea.GitRemoteBranch;
import git4idea.branch.GitBranchUtil;
import git4idea.branch.GitBranchesCollection;
import org.jetbrains.annotations.NonNls;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;

import java.io.BufferedReader;
import java.io.File;
import java.io.FileReader;
import java.util.*;
import java.util.concurrent.Callable;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
 * Reads information about the Git repository from Git service files located in the {@code .git} folder.
 * NB: works with {@link java.io.File}, i.e. reads from disk. Consider using caching.
 * Throws a {@link RepoStateException} in the case of incorrect Git file format.
 *
 * @author Kirill Likhodedov
 */
class GitRepositoryReader {

  private static final Logger LOG = Logger.getInstance(GitRepositoryReader.class);

  private static Pattern BRANCH_PATTERN          = Pattern.compile("ref: refs/heads/(\\S+)"); // branch reference in .git/HEAD
  // this format shouldn't appear, but we don't want to fail because of a space
  private static Pattern BRANCH_WEAK_PATTERN     = Pattern.compile(" *(ref:)? */?refs/heads/(\\S+)");
  private static Pattern COMMIT_PATTERN          = Pattern.compile("[0-9a-fA-F]+"); // commit hash

  @NonNls private static final String REFS_HEADS_PREFIX = "refs/heads/";
  @NonNls private static final String REFS_REMOTES_PREFIX = "refs/remotes/";

  @NotNull private final File          myGitDir;         // .git/
  @NotNull private final File          myHeadFile;       // .git/HEAD
  @NotNull private final File          myRefsHeadsDir;   // .git/refs/heads/
  @NotNull private final File          myRefsRemotesDir; // .git/refs/remotes/
  @NotNull private final File          myPackedRefsFile; // .git/packed-refs

  GitRepositoryReader(@NotNull File gitDir) {
    myGitDir = gitDir;
    RepositoryUtil.assertFileExists(myGitDir, ".git directory not found in " + gitDir);
    myHeadFile = new File(myGitDir, "HEAD");
    RepositoryUtil.assertFileExists(myHeadFile, ".git/HEAD file not found in " + gitDir);
    myRefsHeadsDir = new File(new File(myGitDir, "refs"), "heads");
    myRefsRemotesDir = new File(new File(myGitDir, "refs"), "remotes");
    myPackedRefsFile = new File(myGitDir, "packed-refs");
  }

  @Nullable
  private static Hash createHash(@Nullable String hash) {
    try {
      return hash == null ? GitBranch.DUMMY_HASH : HashImpl.build(hash);
    }
    catch (Throwable t) {
      LOG.info(t);
      return null;
    }
  }

  @NotNull
  public Repository.State readState() {
    if (isMergeInProgress()) {
      return Repository.State.MERGING;
    }
    if (isRebaseInProgress()) {
      return Repository.State.REBASING;
    }
    Head head = readHead();
    if (!head.isBranch) {
      return Repository.State.DETACHED;
    }
    return Repository.State.NORMAL;
  }

  /**
   * Finds current revision value.
   * @return The current revision hash, or <b>{@code null}</b> if current revision is unknown - it is the initial repository state.
   */
  @Nullable
  String readCurrentRevision() {
    final Head head = readHead();
    if (!head.isBranch) { // .git/HEAD is a commit
      return head.ref;
    }

    // look in /refs/heads/<branch name>
    File branchFile = null;
    for (Map.Entry<String, File> entry : readLocalBranches().entrySet()) {
      if (entry.getKey().equals(head.ref)) {
        branchFile = entry.getValue();
      }
    }
    if (branchFile != null) {
      return readBranchFile(branchFile);
    }

    // finally look in packed-refs
    return findBranchRevisionInPackedRefs(head.ref);
  }

  /**
   * If the repository is on branch, returns the current branch
   * If the repository is being rebased, returns the branch being rebased.
   * In other cases of the detached HEAD returns {@code null}.
   */
  @Nullable
  GitLocalBranch readCurrentBranch() {
    Head head = readHead();
    if (head.isBranch) {
      String branchName = head.ref;
      String hash = readCurrentRevision();  // TODO we know the branch name, so no need to read head twice
      Hash h = createHash(hash);
      if (h == null) {
        return null;
      }
      return new GitLocalBranch(branchName, h);
    }
    if (isRebaseInProgress()) {
      GitLocalBranch branch = readRebaseBranch("rebase-apply");
      if (branch == null) {
        branch = readRebaseBranch("rebase-merge");
      }
      return branch;
    }
    return null;
  }

  /**
   * Reads {@code .git/rebase-apply/head-name} or {@code .git/rebase-merge/head-name} to find out the branch which is currently being rebased,
   * and returns the {@link GitBranch} for the branch name written there, or null if these files don't exist.
   */
  @Nullable
  private GitLocalBranch readRebaseBranch(@NonNls String rebaseDirName) {
    File rebaseDir = new File(myGitDir, rebaseDirName);
    if (!rebaseDir.exists()) {
      return null;
    }
    File headName = new File(rebaseDir, "head-name");
    if (!headName.exists()) {
      return null;
    }
    String branchName = RepositoryUtil.tryLoadFile(headName);
    File branchFile = findBranchFile(branchName);
    if (!branchFile.exists()) { // can happen when rebasing from detached HEAD: IDEA-93806
      return null;
    }
    Hash hash = createHash(readBranchFile(branchFile));
    if (hash == null) {
      return null;
    }
    if (branchName.startsWith(REFS_HEADS_PREFIX)) {
      branchName = branchName.substring(REFS_HEADS_PREFIX.length());
    }
    return new GitLocalBranch(branchName, hash);
  }

  @NotNull
  private File findBranchFile(@NotNull String branchName) {
    return new File(myGitDir.getPath() + File.separator + branchName);
  }

  private boolean isMergeInProgress() {
    File mergeHead = new File(myGitDir, "MERGE_HEAD");
    return mergeHead.exists();
  }

  private boolean isRebaseInProgress() {
    File f = new File(myGitDir, "rebase-apply");
    if (f.exists()) {
      return true;
    }
    f = new File(myGitDir, "rebase-merge");
    return f.exists();
  }

  /**
   * Reads the {@code .git/packed-refs} file and tries to find the revision hash for the given reference (branch actually).
   * @param ref short name of the reference to find. For example, {@code master}.
   * @return commit hash, or {@code null} if the given ref wasn't found in {@code packed-refs}
   */
  @Nullable
  private String findBranchRevisionInPackedRefs(final String ref) {
    if (!myPackedRefsFile.exists()) {
      return null;
    }

    List<HashAndName> hashAndNames = readPackedRefsFile(new Condition<HashAndName>() {
      @Override
      public boolean value(HashAndName hashAndName) {
        return hashAndName.name.endsWith(ref);
      }
    });
    HashAndName item = ContainerUtil.getFirstItem(hashAndNames);
    return item == null ? null : item.hash;
  }

  /**
   * @param firstMatchCondition If specified, we read the packed-refs file until the first entry which matches the given condition,
   *                            and return a singleton list of this entry.
   *                            If null, the whole file is read, and all valid entries are returned.
   */
  private List<HashAndName> readPackedRefsFile(@Nullable final Condition<HashAndName> firstMatchCondition) {
    return RepositoryUtil.tryOrThrow(new Callable<List<HashAndName>>() {
      @Override
      public List<HashAndName> call() throws Exception {
        List<HashAndName> hashAndNames = ContainerUtil.newArrayList();
        BufferedReader reader = null;
        try {
          reader = new BufferedReader(new FileReader(myPackedRefsFile));
          for (String line = reader.readLine(); line != null ; line = reader.readLine()) {
            HashAndName hashAndName = parsePackedRefsLine(line);
            if (hashAndName == null) {
              continue;
            }
            if (firstMatchCondition != null) {
              if (firstMatchCondition.value(hashAndName)) {
                return Collections.singletonList(hashAndName);
              }
            }
            else {
              hashAndNames.add(hashAndName);
            }
          }
        }
        finally {
          if (reader != null) {
            reader.close();
          }
        }
        return hashAndNames;
      }
    }, myPackedRefsFile);
  }

  /**
   * @return the list of local branches in this Git repository.
   *         key is the branch name, value is the file.
   */
  private Map<String, File> readLocalBranches() {
    final Map<String, File> branches = new HashMap<String, File>();
    if (!myRefsHeadsDir.exists()) {
      return branches;
    }
    FileUtil.processFilesRecursively(myRefsHeadsDir, new Processor<File>() {
      @Override
      public boolean process(File file) {
        if (!file.isDirectory()) {
          String relativePath = FileUtil.getRelativePath(myRefsHeadsDir, file);
          if (relativePath != null) {
            branches.put(FileUtil.toSystemIndependentName(relativePath), file);
          }
        }
        return true;
      }
    });
    return branches;
  }

  /**
   * @return all branches in this repository. local/remote/active information is stored in branch objects themselves.
   * @param remotes
   */
  GitBranchesCollection readBranches(@NotNull Collection<GitRemote> remotes) {
    Set<GitLocalBranch> localBranches = readUnpackedLocalBranches();
    Set<GitRemoteBranch> remoteBranches = readUnpackedRemoteBranches(remotes);
    GitBranchesCollection packedBranches = readPackedBranches(remotes);
    localBranches.addAll(packedBranches.getLocalBranches());
    remoteBranches.addAll(packedBranches.getRemoteBranches());
    return new GitBranchesCollection(localBranches, remoteBranches);
  }

  /**
   * @return list of branches from refs/heads. active branch is not marked as active - the caller should do this.
   */
  @NotNull
  private Set<GitLocalBranch> readUnpackedLocalBranches() {
    Set<GitLocalBranch> branches = new HashSet<GitLocalBranch>();
    for (Map.Entry<String, File> entry : readLocalBranches().entrySet()) {
      String branchName = entry.getKey();
      File branchFile = entry.getValue();
      String hash = loadHashFromBranchFile(branchFile);
      Hash h = createHash(hash);
      if (h != null) {
        branches.add(new GitLocalBranch(branchName, h));
      }
    }
    return branches;
  }

  @Nullable
  private static String loadHashFromBranchFile(@NotNull File branchFile) {
    try {
      return RepositoryUtil.tryLoadFile(branchFile);
    }
    catch (RepoStateException e) {  // notify about error but don't break the process
      LOG.error("Couldn't read " + branchFile, e);
    }
    return null;
  }

  /**
   * @return list of branches from refs/remotes.
   * @param remotes
   */
  @NotNull
  private Set<GitRemoteBranch> readUnpackedRemoteBranches(@NotNull final Collection<GitRemote> remotes) {
    final Set<GitRemoteBranch> branches = new HashSet<GitRemoteBranch>();
    if (!myRefsRemotesDir.exists()) {
      return branches;
    }
    FileUtil.processFilesRecursively(myRefsRemotesDir, new Processor<File>() {
      @Override
      public boolean process(File file) {
        if (!file.isDirectory() && !file.getName().equalsIgnoreCase(GitRepositoryFiles.HEAD)) {
          final String relativePath = FileUtil.getRelativePath(myGitDir, file);
          if (relativePath != null) {
            String branchName = FileUtil.toSystemIndependentName(relativePath);
            String hash = loadHashFromBranchFile(file);
            Hash h = createHash(hash);
            if (h != null) {
              GitRemoteBranch remoteBranch = GitBranchUtil.parseRemoteBranch(branchName, h, remotes);
              if (remoteBranch != null) {
                branches.add(remoteBranch);
              }
            }
          }
        }
        return true;
      }
    });
    return branches;
  }

  /**
   * @return list of local and remote branches from packed-refs. Active branch is not marked as active.
   * @param remotes
   */
  @NotNull
  private GitBranchesCollection readPackedBranches(@NotNull final Collection<GitRemote> remotes) {
    final Set<GitLocalBranch> localBranches = new HashSet<GitLocalBranch>();
    final Set<GitRemoteBranch> remoteBranches = new HashSet<GitRemoteBranch>();
    if (!myPackedRefsFile.exists()) {
      return GitBranchesCollection.EMPTY;
    }

    List<HashAndName> hashAndNames = readPackedRefsFile(null);
    for (HashAndName hashAndName : hashAndNames) {
      Hash hash = createHash(hashAndName.hash);
      if (hash == null) {
        continue;
      }
      String branchName = hashAndName.name;

      if (branchName.startsWith(REFS_HEADS_PREFIX)) {
        localBranches.add(new GitLocalBranch(branchName, hash));
      }
      else if (branchName.startsWith(REFS_REMOTES_PREFIX)) {
        GitRemoteBranch remoteBranch = GitBranchUtil.parseRemoteBranch(branchName, hash, remotes);
        if (remoteBranch != null) {
          remoteBranches.add(remoteBranch);
        }
      }
    }
    return new GitBranchesCollection(localBranches, remoteBranches);
  }

  @NotNull
  private static String readBranchFile(@NotNull File branchFile) {
    return RepositoryUtil.tryLoadFile(branchFile);
  }

  @NotNull
  private Head readHead() {
    String headContent = RepositoryUtil.tryLoadFile(myHeadFile);
    Matcher matcher = BRANCH_PATTERN.matcher(headContent);
    if (matcher.matches()) {
      return new Head(true, matcher.group(1));
    }

    if (COMMIT_PATTERN.matcher(headContent).matches()) {
      return new Head(false, headContent);
    }
    matcher = BRANCH_WEAK_PATTERN.matcher(headContent);
    if (matcher.matches()) {
      LOG.info(".git/HEAD has not standard format: [" + headContent + "]. We've parsed branch [" + matcher.group(1) + "]");
      return new Head(true, matcher.group(1));
    }
    throw new RepoStateException("Invalid format of the .git/HEAD file: [" + headContent + "]");
  }

  /**
   * Parses a line from the .git/packed-refs file returning a pair of hash and ref name.
   * Comments and tags are ignored, and null is returned.
   * Incorrectly formatted lines are ignored, a warning is printed to the log, null is returned.
   * A line indicating a hash which an annotated tag (specified in the previous line) points to, is ignored: null is returned.
   */
  @Nullable
  private static HashAndName parsePackedRefsLine(@NotNull String line) {
    line = line.trim();
    if (line.isEmpty()) {
      return null;
    }
    char firstChar = line.charAt(0);
    if (firstChar == '#') { // ignoring comments
      return null;
    }
    if (firstChar == '^') {
      // ignoring the hash which an annotated tag above points to
      return null;
    }
    String hash = null;
    int i;
    for (i = 0; i < line.length(); i++) {
      char c = line.charAt(i);
      if (!Character.isLetterOrDigit(c)) {
        hash = line.substring(0, i);
        break;
      }
    }
    if (hash == null) {
      LOG.warn("Ignoring invalid packed-refs line: [" + line + "]");
      return null;
    }

    String branch = null;
    int start = i;
    if (start < line.length() && line.charAt(start++) == ' ') {
      for (i = start; i < line.length(); i++) {
        char c = line.charAt(i);
        if (Character.isWhitespace(c)) {
          break;
        }
      }
      branch = line.substring(start, i);
    }

    if (branch == null) {
      LOG.warn("Ignoring invalid packed-refs line: [" + line + "]");
      return null;
    }
    return new HashAndName(shortBuffer(hash.trim()), shortBuffer(branch));
  }

  @NotNull
  private static String shortBuffer(String raw) {
    return new String(raw);
  }

  private static class HashAndName {
    private final String hash;
    private final String name;

    public HashAndName(String hash, String name) {
      this.hash = hash;
      this.name = name;
    }
  }

  /**
   * Container to hold two information items: current .git/HEAD value and is Git on branch.
   */
  private static class Head {
    @NotNull private final String ref;
    private final boolean isBranch;

    Head(boolean branch, @NotNull String ref) {
      isBranch = branch;
      this.ref = ref;
    }
  }


}