Skip to content

Commit bacf00f

Browse files
authored
feat(stack): add Claude session ID support (#912)
Add support for embedding Claude Code session IDs in commits, enabling users to later resume Claude sessions that worked on specific commits. How it works: - When CLAUDE_SESSION_ID env var is set, the commit-msg hook adds a `Claude-Session-Id:` trailer to commit messages - The prepare-commit-msg hook preserves this trailer during amend operations with -m flag (like Change-Id) - The session ID is stripped from PR descriptions The `mergify stack setup` command now also installs Claude Code hooks: - `.claude/settings.json` - Configures SessionStart hook - `.claude/hooks/session-start.sh` - Captures session ID from Claude New CLI command: - `mergify stack session` - Extract session ID from commits - `mergify stack session --launch` - Launch Claude with --resume flag Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> Claude-Session-Id: 4d560622-e233-4913-8434-0f7bf51e9f50
1 parent baa5ac7 commit bacf00f

File tree

13 files changed

+499
-76
lines changed

13 files changed

+499
-76
lines changed

.claude/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
hooks/

mergify_cli/stack/changes.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,10 @@
3030

3131

3232
CHANGEID_RE = re.compile(r"Change-Id: (I[0-9a-z]{40})")
33+
CLAUDE_SESSION_ID_RE = re.compile(r"Claude-Session-Id:\s*(\S+)")
3334

3435
ChangeId = typing.NewType("ChangeId", str)
36+
ClaudeSessionId = typing.NewType("ClaudeSessionId", str)
3537
RemoteChanges = typing.NewType(
3638
"RemoteChanges",
3739
dict[ChangeId, github_types.PullRequest],
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
#!/usr/bin/env bash
2+
# Capture Claude session ID and export it for git hooks
3+
4+
INPUT=$(cat)
5+
6+
# Check if jq is available
7+
if ! command -v jq >/dev/null 2>&1; then
8+
exit 0
9+
fi
10+
11+
# Extract session ID, handling invalid JSON gracefully
12+
if ! CLAUDE_SESSION_ID=$(echo "$INPUT" | jq -er '.session_id // empty' 2>/dev/null); then
13+
CLAUDE_SESSION_ID=""
14+
fi
15+
16+
if [ -n "$CLAUDE_ENV_FILE" ] && [ -n "$CLAUDE_SESSION_ID" ]; then
17+
printf 'export CLAUDE_SESSION_ID=%q\n' "$CLAUDE_SESSION_ID" >> "$CLAUDE_ENV_FILE"
18+
fi
19+
20+
exit 0
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{
2+
"hooks": {
3+
"SessionStart": [
4+
{
5+
"hooks": [
6+
{
7+
"type": "command",
8+
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/session-start.sh"
9+
}
10+
]
11+
}
12+
]
13+
}
14+
}

mergify_cli/stack/cli.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
github_action_auto_rebase as stack_github_action_auto_rebase_mod,
1616
)
1717
from mergify_cli.stack import push as stack_push_mod
18+
from mergify_cli.stack import session as stack_session_mod
1819
from mergify_cli.stack import setup as stack_setup_mod
1920

2021

@@ -274,3 +275,30 @@ async def github_action_auto_rebase(ctx: click.Context) -> None:
274275
ctx.obj["github_server"],
275276
ctx.obj["token"],
276277
)
278+
279+
280+
@stack.command(help="Get Claude session ID from a commit") # type: ignore[untyped-decorator]
281+
@click.option(
282+
"--commit",
283+
"-c",
284+
default="HEAD",
285+
help="Commit to extract session ID from (default: HEAD)",
286+
)
287+
@click.option(
288+
"--launch",
289+
"-l",
290+
is_flag=True,
291+
help="Launch Claude with the extracted session ID",
292+
)
293+
@utils.run_with_asyncio
294+
async def session(*, commit: str, launch: bool) -> None:
295+
"""Extract and optionally launch Claude session from commit."""
296+
session_id = await stack_session_mod.get_session_id_from_commit(commit)
297+
if session_id is None:
298+
console.print(f"No Claude-Session-Id found in commit {commit}", style="yellow")
299+
return
300+
301+
console.print(f"Claude-Session-Id: {session_id}")
302+
303+
if launch:
304+
stack_session_mod.launch_claude_session(session_id)

mergify_cli/stack/hooks/commit-msg

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,3 +62,18 @@ if ! mv "${dest}" "$1" ; then
6262
echo "cannot mv ${dest} to $1"
6363
exit 1
6464
fi
65+
66+
# Add Claude-Session-Id if CLAUDE_SESSION_ID env var is set and not already present
67+
if test -n "$CLAUDE_SESSION_ID"; then
68+
if ! grep -q "^Claude-Session-Id:" "$1"; then
69+
if ! git -c trailer.ifexists=doNothing interpret-trailers \
70+
--trailer "Claude-Session-Id: ${CLAUDE_SESSION_ID}" < "$1" > "${dest}" ; then
71+
echo "cannot insert Claude-Session-Id line in $1"
72+
exit 1
73+
fi
74+
if ! mv "${dest}" "$1" ; then
75+
echo "cannot mv ${dest} to $1"
76+
exit 1
77+
fi
78+
fi
79+
fi

mergify_cli/stack/hooks/prepare-commit-msg

Lines changed: 68 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,9 @@
1414
# License for the specific language governing permissions and limitations
1515
# under the License.
1616

17-
# This hook preserves Change-Id during amend operations where the message
18-
# is provided via -m or -F flags (which would otherwise lose the Change-Id).
17+
# This hook preserves Change-Id and Claude-Session-Id during amend operations
18+
# where the message is provided via -m or -F flags (which would otherwise lose
19+
# these trailers).
1920
#
2021
# Arguments:
2122
# $1 - Path to the commit message file
@@ -30,9 +31,9 @@ MSG_FILE="$1"
3031
SOURCE="${2:-}"
3132

3233
# If source is "commit" (from --amend or -c/-C), git already preserves the
33-
# original message content including Change-Id, so we don't need to do anything.
34+
# original message content including trailers, so we don't need to do anything.
3435
# This hook is specifically for the case where -m or -F is used with --amend,
35-
# which sets source to "message" and loses the original Change-Id.
36+
# which sets source to "message" and loses the original trailers.
3637

3738
# Only act if we have a message file
3839
if test ! -f "$MSG_FILE"; then
@@ -44,44 +45,74 @@ if ! git rev-parse --verify HEAD >/dev/null 2>&1; then
4445
exit 0
4546
fi
4647

47-
# Check if the current message already has a Change-Id
48-
if grep -q "^Change-Id: I[0-9a-f]\{40\}$" "$MSG_FILE"; then
48+
# Function to detect if this is an amend operation with -m flag
49+
is_amend_with_m_flag() {
50+
# Heuristic to detect amend: During --amend, git sets GIT_AUTHOR_DATE to preserve
51+
# the original author date. This date should exactly match HEAD's author date.
52+
# For a regular commit, GIT_AUTHOR_DATE is not set by git.
53+
# We also check that source is "message" (set by git for both -m and -F flags).
54+
if test "$SOURCE" != "message" || test -z "$GIT_AUTHOR_DATE"; then
55+
return 1
56+
fi
57+
58+
# Get HEAD's author date in the same format git uses internally (raw format)
59+
# The raw format is: seconds-since-epoch timezone (e.g., "1234567890 +0000")
60+
HEAD_AUTHOR_DATE_RAW=$(git log -1 --format=%ad --date=raw HEAD 2>/dev/null)
61+
if test -z "$HEAD_AUTHOR_DATE_RAW"; then
62+
return 1
63+
fi
64+
65+
# Extract epoch from GIT_AUTHOR_DATE (handles various formats)
66+
# During amend, GIT_AUTHOR_DATE is in format: "@epoch tz" (e.g., "@1234567890 +0000")
67+
# Remove the @ prefix if present
68+
GIT_AUTHOR_EPOCH=$(echo "$GIT_AUTHOR_DATE" | cut -d' ' -f1 | tr -d '@')
69+
HEAD_AUTHOR_EPOCH=$(echo "$HEAD_AUTHOR_DATE_RAW" | cut -d' ' -f1)
70+
71+
# If the epoch timestamps match, this is likely an amend operation.
72+
# Additional check: the author date should be at least 2 seconds in the past.
73+
# This prevents false positives when commits happen in quick succession
74+
# (e.g., in automated tests or scripts) where timestamps might match by coincidence.
75+
if test "$GIT_AUTHOR_EPOCH" != "$HEAD_AUTHOR_EPOCH"; then
76+
return 1
77+
fi
78+
79+
CURRENT_EPOCH=$(date +%s)
80+
AGE=$((CURRENT_EPOCH - GIT_AUTHOR_EPOCH))
81+
if test "$AGE" -lt 2; then
82+
return 1
83+
fi
84+
85+
return 0
86+
}
87+
88+
# Only proceed if this is an amend with -m flag
89+
if ! is_amend_with_m_flag; then
4990
exit 0
5091
fi
5192

52-
# Get Change-Id from HEAD if it exists
53-
HEAD_CHANGEID=$(git log -1 --format=%B HEAD 2>/dev/null | grep "^Change-Id: I[0-9a-f]\{40\}$" | tail -1)
54-
if test -z "$HEAD_CHANGEID"; then
55-
exit 0
93+
# Track if we need to add a blank line before trailers
94+
NEED_BLANK_LINE=false
95+
96+
# Preserve Change-Id if missing from current message but present in HEAD
97+
if ! grep -q "^Change-Id: I[0-9a-f]\{40\}$" "$MSG_FILE"; then
98+
HEAD_CHANGEID=$(git log -1 --format=%B HEAD 2>/dev/null | grep "^Change-Id: I[0-9a-f]\{40\}$" | tail -1)
99+
if test -n "$HEAD_CHANGEID"; then
100+
if test "$NEED_BLANK_LINE" = "false"; then
101+
echo "" >> "$MSG_FILE"
102+
NEED_BLANK_LINE=true
103+
fi
104+
echo "$HEAD_CHANGEID" >> "$MSG_FILE"
105+
fi
56106
fi
57107

58-
# Heuristic to detect amend: During --amend, git sets GIT_AUTHOR_DATE to preserve
59-
# the original author date. This date should exactly match HEAD's author date.
60-
# For a regular commit, GIT_AUTHOR_DATE is not set by git.
61-
# We also check that source is "message" (set by git for both -m and -F flags).
62-
if test "$SOURCE" = "message" && test -n "$GIT_AUTHOR_DATE"; then
63-
# Get HEAD's author date in the same format git uses internally (raw format)
64-
# The raw format is: seconds-since-epoch timezone (e.g., "1234567890 +0000")
65-
HEAD_AUTHOR_DATE_RAW=$(git log -1 --format=%ad --date=raw HEAD 2>/dev/null)
66-
if test -n "$HEAD_AUTHOR_DATE_RAW"; then
67-
# Extract epoch from GIT_AUTHOR_DATE (handles various formats)
68-
# During amend, GIT_AUTHOR_DATE is in format: "@epoch tz" (e.g., "@1234567890 +0000")
69-
# Remove the @ prefix if present
70-
GIT_AUTHOR_EPOCH=$(echo "$GIT_AUTHOR_DATE" | cut -d' ' -f1 | tr -d '@')
71-
HEAD_AUTHOR_EPOCH=$(echo "$HEAD_AUTHOR_DATE_RAW" | cut -d' ' -f1)
72-
73-
# If the epoch timestamps match, this is likely an amend operation.
74-
# Additional check: the author date should be at least 2 seconds in the past.
75-
# This prevents false positives when commits happen in quick succession
76-
# (e.g., in automated tests or scripts) where timestamps might match by coincidence.
77-
if test "$GIT_AUTHOR_EPOCH" = "$HEAD_AUTHOR_EPOCH"; then
78-
CURRENT_EPOCH=$(date +%s)
79-
AGE=$((CURRENT_EPOCH - GIT_AUTHOR_EPOCH))
80-
if test "$AGE" -ge 2; then
81-
# This looks like an amend with -m flag - preserve the Change-Id
82-
echo "" >> "$MSG_FILE"
83-
echo "$HEAD_CHANGEID" >> "$MSG_FILE"
84-
fi
108+
# Preserve Claude-Session-Id if missing from current message but present in HEAD
109+
if ! grep -q "^Claude-Session-Id:" "$MSG_FILE"; then
110+
HEAD_CLAUDE_SESSION_ID=$(git log -1 --format=%B HEAD 2>/dev/null | grep "^Claude-Session-Id:" | tail -1)
111+
if test -n "$HEAD_CLAUDE_SESSION_ID"; then
112+
if test "$NEED_BLANK_LINE" = "false"; then
113+
echo "" >> "$MSG_FILE"
114+
NEED_BLANK_LINE=true
85115
fi
116+
echo "$HEAD_CLAUDE_SESSION_ID" >> "$MSG_FILE"
86117
fi
87118
fi

mergify_cli/stack/push.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ def format_pull_description(
5959
depends_on_header = f"\n\nDepends-On: #{depends_on['number']}"
6060

6161
message = changes.CHANGEID_RE.sub("", message).rstrip("\n")
62+
message = changes.CLAUDE_SESSION_ID_RE.sub("", message).rstrip("\n")
6263
message = DEPENDS_ON_RE.sub("", message).rstrip("\n")
6364

6465
return message + depends_on_header

mergify_cli/stack/session.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
#
2+
# Copyright © 2021-2026 Mergify SAS
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License"); you may
5+
# not use this file except in compliance with the License. You may obtain
6+
# a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12+
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13+
# License for the specific language governing permissions and limitations
14+
# under the License.
15+
16+
"""Claude session ID management for mergify-cli stack."""
17+
18+
from __future__ import annotations
19+
20+
import shutil
21+
import subprocess
22+
23+
from mergify_cli import console
24+
from mergify_cli import utils
25+
from mergify_cli.stack.changes import CLAUDE_SESSION_ID_RE
26+
from mergify_cli.stack.changes import ClaudeSessionId
27+
28+
29+
async def get_session_id_from_commit(
30+
commit_sha: str = "HEAD",
31+
) -> ClaudeSessionId | None:
32+
"""Extract Claude-Session-Id from a commit message."""
33+
message = await utils.git("log", "-1", "--format=%B", commit_sha)
34+
match = CLAUDE_SESSION_ID_RE.search(message)
35+
if match:
36+
return ClaudeSessionId(match.group(1))
37+
return None
38+
39+
40+
def launch_claude_session(session_id: str) -> None:
41+
"""Launch Claude Code with the given session ID."""
42+
claude_path = shutil.which("claude")
43+
if not claude_path:
44+
console.print("Error: 'claude' command not found in PATH", style="red")
45+
return
46+
47+
console.print(f"Launching Claude with session: {session_id}")
48+
result = subprocess.run([claude_path, "--resume", session_id], check=False) # noqa: S603
49+
if result.returncode != 0:
50+
console.print(
51+
f"Error: 'claude' exited with code {result.returncode}",
52+
style="red",
53+
)

0 commit comments

Comments
 (0)