How gh-stack Implements File Locking for the Stack File (.git/gh-stack.lock)
gh-stack uses an advisory exclusive file lock on .git/gh-stack.lock with a 5-second timeout to prevent concurrent modifications to the stack state file, implementing retry logic with 100ms intervals in internal/stack/lock.go.
The github/gh-stack CLI tool manages stacked Git branches by persisting workflow state to a JSON file located at .git/gh-stack. To prevent data corruption when multiple processes access this state simultaneously, the codebase implements a robust file locking mechanism centered around the .git/gh-stack.lock advisory lock file.
Lock File Architecture
The stack state resides in .git/gh-stack, a JSON file tracking branch dependencies and metadata. To protect this file from race conditions, gh-stack creates a separate advisory lock file at .git/gh-stack.lock. This lock is exclusive and process-wide, ensuring only one gh-stack command can write to the state file at any given time.
In internal/stack/lock.go, the constant lockFileName = "gh-stack.lock" (line 10) defines the lock file name. The full path combines this name with the Git directory path passed to the locking functions.
Lock Acquisition and Retry Logic
The Lock(gitDir string) function in internal/stack/lock.go implements a blocking retry mechanism to acquire exclusive access:
- File Opening – The function opens or creates the lock file using
os.OpenFilewith read-write permissions (line 54). - Advisory Locking – It attempts to acquire an exclusive lock via
tryLockFile, a thin wrapper around theflocksystem call. - Retry Loop – If the lock is busy, the function sleeps for
lockRetryInterval = 100msbetween attempts (lines 61-76). - Timeout Handling – After
LockTimeout = 5s(line 35), if the lock remains unavailable, the function returns aLockErrorindicating another gh-stack process holds the lock (lines 70-73). - Error Propagation – Non-busy errors (such as bad file descriptors) abort the retry immediately and return the underlying error (lines 65-68).
import (
"fmt"
"github.com/github/gh-stack/internal/stack"
)
func editStackAtomically(gitDir string) error {
// Acquire exclusive lock with 5-second timeout
lck, err := stack.Lock(gitDir)
if err != nil {
return fmt.Errorf("cannot obtain lock: %w", err)
}
defer lck.Unlock() // Ensure release even on panic
// Load, modify, and save the stack while holding the lock
s, err := stack.Load(gitDir)
if err != nil {
return err
}
s.YourModification()
return stack.Save(gitDir, s)
}
Lock Release Strategy
The Unlock() method follows a careful sequence to prevent race conditions. Defined in internal/stack/lock.go (lines 84-89), the function:
- Calls
unlockFileto release the advisory lock on the file handle - Closes the file descriptor held in the
FileLockstruct
Crucially, the implementation intentionally does not delete the lock file (lines 79-82). This prevents a race condition where a new process could create a fresh inode while another process still holds a lock on the old inode, potentially allowing concurrent access.
Automatic Locking in Stack Persistence
While manual lock management is possible, most developers interact with locking indirectly through the persistence API. The stack.Save(dir, data) function in internal/stack/file.go automatically coordinates locking:
import (
"github.com/github/gh-stack/internal/stack"
)
func updateStack(gitDir string) error {
// Load current stack
st, err := stack.Load(gitDir)
if err != nil {
return err
}
// Apply modifications
st.Branches = append(st.Branches, "feature/new")
// Save internally locks .git/gh-stack.lock, writes JSON, then unlocks
return stack.Save(gitDir, st)
}
This integration guarantees atomic updates: the lock is acquired before writing the JSON file and released immediately after, ensuring no other process can interleave writes that would corrupt the stack state.
Handling Lock Timeouts
When the 5-second timeout expires, gh-stack returns a LockError that callers can handle gracefully:
if err := stack.Save(dir, data); err != nil {
var lockErr *stack.LockError
if errors.As(err, &lockErr) {
fmt.Println("Another gh-stack process is active; retry later.")
} else {
fmt.Printf("Failed to save stack: %v\n", err)
}
}
This error handling pattern allows CLI commands to provide actionable feedback when concurrent operations block access to the stack state.
Summary
- Advisory Locking: gh-stack uses
.git/gh-stack.lockas an advisory exclusive lock to protect the JSON state file at.git/gh-stack - Timeout Protection: The
Lock()function ininternal/stack/lock.goretries every 100ms for up to 5 seconds before returning aLockError - Safe Release: The
Unlock()method releases the flock and closes the file descriptor without deleting the lock file, avoiding inode race conditions - Automatic Integration: The
stack.Save()function handles locking transparently, ensuring atomic writes to the stack state - Process Safety: Only one gh-stack process can hold the write lock at a time, preventing corruption during concurrent stacked-branch operations
Frequently Asked Questions
What happens if two gh-stack processes try to modify the stack simultaneously?
The second process blocks for up to 5 seconds while retrying every 100ms to acquire the exclusive lock on .git/gh-stack.lock. If the first process releases the lock within this window, the second process proceeds. If the timeout expires, the second process receives a LockError and fails with a message indicating that another gh-stack instance is active.
Why doesn't gh-stack delete the lock file after unlocking?
The implementation intentionally preserves the lock file to avoid an inode race condition. If the file were deleted while another process held an open file descriptor, a new process could create a file with the same name but a different inode, allowing concurrent access. By keeping the file and only releasing the advisory lock, gh-stack ensures that all processes reference the same underlying file system object.
How does the locking mechanism integrate with stack persistence operations?
The public API stack.Save(dir, data) in internal/stack/file.go internally calls Lock(dir) before writing the JSON state and invokes Unlock() after the operation completes. This means developers rarely need to manually manage locks—calling stack.Save() automatically provides atomic, exclusive access to the stack file.
Is the gh-stack lock compatible with standard Git locking?
No, the .git/gh-stack.lock file uses advisory file locks (flock) independent of Git's internal reference locking mechanisms. While Git uses its own locking strategy for refs and the index, gh-stack's lock specifically protects the custom JSON stack state file. The lock is advisory, meaning it only prevents other gh-stack processes from accessing the file—standard Git operations or other tools can still read or write the file, though this may corrupt the stack state.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →