1616 */
1717package org .apache .commons .release .plugin .internal ;
1818
19+ import java .io .BufferedReader ;
1920import java .io .IOException ;
2021import java .nio .charset .StandardCharsets ;
2122import java .nio .file .Files ;
@@ -37,6 +38,14 @@ public final class GitUtils {
3738 * <p>See <a href="https://git-scm.com/docs/gitrepository-layout">gitrepository-layout</a>.</p>
3839 */
3940 private static final String GITDIR_PREFIX = "gitdir: " ;
41+ /**
42+ * Maximum number of symbolic-ref hops before we give up (to avoid cycles).
43+ */
44+ private static final int MAX_REF_DEPTH = 5 ;
45+ /**
46+ * Prefix used in {@code HEAD} and ref files to indicate a symbolic reference.
47+ */
48+ private static final String REF_PREFIX = "ref: " ;
4049 /**
4150 * The SCM URI prefix for Git repositories.
4251 */
@@ -79,14 +88,37 @@ private static Path findGitDir(final Path path) throws IOException {
7988 */
8089 public static String getCurrentBranch (final Path repositoryPath ) throws IOException {
8190 final Path gitDir = findGitDir (repositoryPath );
82- final String head = new String ( Files . readAllBytes ( gitDir . resolve ( "HEAD" )), StandardCharsets . UTF_8 ). trim ( );
91+ final String head = readHead ( gitDir );
8392 if (head .startsWith ("ref: refs/heads/" )) {
8493 return head .substring ("ref: refs/heads/" .length ());
8594 }
86- // detached HEAD — return the commit SHA
95+ // Detached HEAD: the file contains the commit SHA.
8796 return head ;
8897 }
8998
99+ /**
100+ * Gets the commit SHA pointed to by {@code HEAD}.
101+ *
102+ * <p>Handles loose refs under {@code <gitDir>/refs/...}, packed refs in {@code <gitDir>/packed-refs},
103+ * symbolic indirection (a ref file that itself contains {@code ref: ...}), and detached HEAD.</p>
104+ *
105+ * @param repositoryPath A path inside the Git repository.
106+ * @return The hex-encoded commit SHA.
107+ * @throws IOException If the {@code .git} directory cannot be found, the ref cannot be resolved,
108+ * or the symbolic chain is deeper than {@value #MAX_REF_DEPTH}.
109+ */
110+ public static String getHeadCommit (final Path repositoryPath ) throws IOException {
111+ final Path gitDir = findGitDir (repositoryPath );
112+ String value = readHead (gitDir );
113+ for (int i = 0 ; i < MAX_REF_DEPTH ; i ++) {
114+ if (!value .startsWith (REF_PREFIX )) {
115+ return value ;
116+ }
117+ value = resolveRef (gitDir , value .substring (REF_PREFIX .length ()));
118+ }
119+ throw new IOException ("Symbolic ref chain exceeds " + MAX_REF_DEPTH + " hops in: " + gitDir );
120+ }
121+
90122 /**
91123 * Returns the Git tree hash for the given directory.
92124 *
@@ -102,6 +134,75 @@ public static String gitTree(final Path path) throws IOException {
102134 return Hex .encodeHexString (GitIdentifiers .treeId (digest , path ));
103135 }
104136
137+ /**
138+ * Reads and trims the {@code HEAD} file of the given Git directory.
139+ *
140+ * @param gitDir The {@code .git} directory.
141+ * @return The trimmed contents of {@code <gitDir>/HEAD}.
142+ * @throws IOException If the file cannot be read.
143+ */
144+ private static String readHead (final Path gitDir ) throws IOException {
145+ return new String (Files .readAllBytes (gitDir .resolve ("HEAD" )), StandardCharsets .UTF_8 ).trim ();
146+ }
147+
148+ /**
149+ * Returns the directory that holds shared repository state (loose refs, {@code packed-refs}).
150+ * In a linked worktree this is read from {@code <gitDir>/commondir}; otherwise it is
151+ * {@code gitDir} itself.
152+ *
153+ * @param gitDir The {@code .git} directory.
154+ * @return The shared-state directory.
155+ * @throws IOException If {@code commondir} exists but cannot be read.
156+ */
157+ private static Path resolveCommonDir (final Path gitDir ) throws IOException {
158+ final Path commonDir = gitDir .resolve ("commondir" );
159+ if (Files .isRegularFile (commonDir )) {
160+ final String value = new String (Files .readAllBytes (commonDir ), StandardCharsets .UTF_8 ).trim ();
161+ return gitDir .resolve (value ).normalize ();
162+ }
163+ return gitDir ;
164+ }
165+
166+ /**
167+ * Resolves a single ref (e.g. {@code refs/heads/foo}) to its stored value.
168+ *
169+ * <p>The return value is either a commit SHA or another {@code ref: ...} line, which the caller continues to resolve.</p>
170+ *
171+ * <p>In a linked worktree, loose and packed refs are stored in the "common dir" (usually the
172+ * main repository's {@code .git}), which is pointed to by {@code <gitDir>/commondir}.</p>
173+ *
174+ * @param gitDir The {@code .git} directory.
175+ * @param refPath The ref path relative to the common dir (e.g. {@code refs/heads/main}).
176+ * @return Either a commit SHA or another {@code ref: ...} line to be resolved by the caller.
177+ * @throws IOException If the ref is not found as a loose file or in {@code packed-refs}.
178+ */
179+ private static String resolveRef (final Path gitDir , final String refPath ) throws IOException {
180+ final Path refsDir = resolveCommonDir (gitDir );
181+ final Path refFile = refsDir .resolve (refPath );
182+ if (Files .isRegularFile (refFile )) {
183+ return new String (Files .readAllBytes (refFile ), StandardCharsets .UTF_8 ).trim ();
184+ }
185+ final Path packed = refsDir .resolve ("packed-refs" );
186+ if (Files .isRegularFile (packed )) {
187+ try (BufferedReader reader = Files .newBufferedReader (packed , StandardCharsets .UTF_8 )) {
188+ // packed-refs format: one ref per line as "<sha> <refname>", with '#' header lines,
189+ // blank lines, and "^<sha>" peeled-tag continuation lines that we skip.
190+ // See https://git-scm.com/docs/gitrepository-layout
191+ String line ;
192+ while ((line = reader .readLine ()) != null ) {
193+ if (line .isEmpty () || line .charAt (0 ) == '#' || line .charAt (0 ) == '^' ) {
194+ continue ;
195+ }
196+ final int space = line .indexOf (' ' );
197+ if (space > 0 && refPath .equals (line .substring (space + 1 ))) {
198+ return line .substring (0 , space );
199+ }
200+ }
201+ }
202+ }
203+ throw new IOException ("Cannot resolve ref: " + refPath );
204+ }
205+
105206 /**
106207 * Converts an SCM URI to a download URI suffixed with the current branch name.
107208 *
0 commit comments