Documentation
¶
Index ¶
- Constants
- Variables
- func ConfigDir() (string, error)
- func GenerateStackHash(name string) string
- func MutateBranchCache(repoDir, branchName string, ...) error
- func PRNumberFromURL(url string) int
- type Branch
- type BranchCache
- type BranchTree
- type CacheConfig
- type Config
- func (c *Config) GetBaseBranch(repoPath string) string
- func (c *Config) GetCdAfterNew(repoPath string) bool
- func (c *Config) GetInitSubmodules(repoPath string) bool
- func (c *Config) GetRepoConfig(repoPath string) *RepoConfig
- func (c *Config) GetSyncStrategy(repoPath string) string
- func (c *Config) GetUseWorktrees(repoPath string) bool
- func (c *Config) GetWorktreeBaseDir(repoPath string) string
- func (c *Config) Save() error
- func (c *Config) SetRepoConfig(repoPath string, repoCfg *RepoConfig)
- type RepoConfig
- type Stack
- func (s *Stack) AddBranch(branchName, parentName string)
- func (s *Stack) AddSubtree(branchName string, subtree BranchTree, parentName string) bool
- func (s *Stack) DisplayName() string
- func (s *Stack) ExtractSubtree(branchName string) BranchTree
- func (s *Stack) FindBranch(branchName string) (parent string, found bool)
- func (s *Stack) GetBranches(cache *CacheConfig) []*Branch
- func (s *Stack) GetChildren(branchName string) []string
- func (s *Stack) HasBranch(branchName string) bool
- func (s *Stack) IsFullyMerged(cache *CacheConfig) bool
- func (s *Stack) PopulateBranches()
- func (s *Stack) PopulateBranchesWithCache(cache *CacheConfig)
- func (s *Stack) RemoveBranch(branchName string)
- func (s *Stack) RenameBranchInTree(oldName, newName string) bool
- func (s *Stack) ReparentBranch(branchName, newParent string)
- type StackConfig
- type SyncLock
Constants ¶
const ( AgentSessionWorkMode = "work" // `ezs agent` default (work session) AgentSessionFeatureMode = "feature" // `ezs agent feature` builder mode )
AgentSessionWorkMode is the mode tag stored alongside an agent session. "" (empty) is treated as the work-mode default for entries written before mode tracking was added — never write an empty mode for a fresh session.
const RemoteNoPush = "_nopush"
RemoteNoPush is a sentinel value indicating that push is not allowed for this branch (e.g., a fork PR where maintainerCanModify is false).
Variables ¶
var ErrLockTimeout = errors.New("timed out waiting for lock held by another process")
ErrLockTimeout is returned by acquireFileLock when LockTimeout elapses while a peer process actively holds the lock. Callers can distinguish this from "lock subsystem broken" failures (open errors, permission denied) so they can decide whether unlocked-fallback is appropriate. In particular, the LoadStackConfig migration path uses this to skip the persist step rather than racing the peer with an unlocked write.
var LockPollInterval = 50 * time.Millisecond
LockPollInterval is the gap between non-blocking acquisition attempts while waiting. 50ms keeps CPU well under 1% even if many clients are queued, and bounds the wakeup latency when the holder releases.
var LockTimeout = 60 * time.Second
LockTimeout is the absolute ceiling on how long acquireFileLock will wait before giving up. Without this, a wedged ezstack process (debugger, SIGSTOP, NFS hang) would hang every future invocation forever. Tunable for tests.
var LockWaitNotice = 2 * time.Second
LockWaitNotice is the duration we'll silently wait for the lock before printing a "waiting" message to stderr. Tunable for tests.
var MergeConflictHook func(kind, key string) = func(kind, key string) { fmt.Fprintf(os.Stderr, "ezs: warning: concurrent edit to %s %q detected during save; "+ "another ezs process modified the same %s in between our load and save. "+ "Their changes were overwritten (last-writer-wins).\n", kind, key, kind, ) }
MergeConflictHook is invoked once per (kind, key) where the three-way merge encountered a same-target concurrent edit — i.e. both `mine` and `theirs` diverged from `orig` for the same key, in different ways. Kind is "stack" or "branch". The merge resolves last-writer-wins for the modify-vs-modify and add-vs-add cases (mine wins); for delete-vs-modify the deleting side wins by policy. The hook lets the caller surface the lost peer-update either way.
Concrete cases that fire the hook:
- both added the same key with different values (add-vs-add)
- we modified, they modified differently (modify-vs-modify)
- we modified, they deleted (modify-vs-delete; mine wins)
- we deleted, they modified (delete-vs-modify; deletion wins)
Default writes a one-line warning to stderr. Tests override it to capture the call. Set to a no-op func to silence (callers that want silence: don't use nil — that path is reserved for "default behavior").
Functions ¶
func ConfigDir ¶
ConfigDir returns the path to the ezstack config directory. Checks EZSTACK_HOME first, then defaults to $HOME/.ezstack.
func GenerateStackHash ¶
GenerateStackHash generates a 7-char hex hash from a stack name using FNV-32a
func MutateBranchCache ¶ added in v4.8.0
func MutateBranchCache(repoDir, branchName string, fn func(current *BranchCache) (next *BranchCache, err error)) error
MutateBranchCache atomically loads, modifies, and saves a single branch's cache entry under the stacks.json file lock. The mutator receives the current entry (or nil if absent) and returns the next value (or nil to delete the entry).
Use this instead of LoadCacheConfig + SetBranchCache + Save when only a few branches need updating: the load-modify-save pattern is racy across processes because Save replaces the whole branches map with the in-memory copy, which silently discards updates to other branches that landed between the load and the save. MutateBranchCache loads inside the lock and only ever rewrites the named branch (plus delete-on-nil), so peer writes to other branches survive.
A non-nil error returned by fn aborts the save. The pointer fn returns may but need not alias the pointer it received — both work.
func PRNumberFromURL ¶
PRNumberFromURL extracts the PR number from a GitHub PR URL. e.g. "https://github.com/owner/repo/pull/123" → 123 Returns 0 if the URL is empty or unparseable.
Types ¶
type Branch ¶
type Branch struct {
Name string `json:"name"`
Parent string `json:"parent"`
WorktreePath string `json:"worktree_path"`
PRNumber int `json:"-"` // Runtime-only: derived from PRUrl via PRNumberFromURL
PRUrl string `json:"pr_url,omitempty"`
PRState string `json:"pr_state,omitempty"` // Cached: "OPEN", "DRAFT", "MERGED", "CLOSED"
BaseBranch string `json:"base_branch"` // original tree parent, used for display ordering
IsRemote bool `json:"is_remote,omitempty"` // branch belongs to another contributor
IsMerged bool `json:"is_merged,omitempty"`
Remote string `json:"remote,omitempty"` // Git remote to push to (empty means "origin")
}
Branch represents a single branch in a stack, constructed from the tree and cache at runtime.
func SortBranchesTopologically ¶
SortBranchesTopologically sorts branches so parents come before children This ensures the display shows the correct parent -> child order IMPORTANT: When a parent branch is merged and its children are reparented to main, the merged branch should still appear in its original position (before its former children). We use BaseBranch to detect the original parent-child relationships.
func (*Branch) EffectiveRemote ¶
EffectiveRemote returns the remote for this branch, defaulting to "origin".
type BranchCache ¶
type BranchCache struct {
WorktreePath string `json:"worktree_path,omitempty"`
PRNumber int `json:"-"` // Runtime-only: derived from PRUrl via PRNumberFromURL
PRUrl string `json:"pr_url,omitempty"`
PRState string `json:"pr_state,omitempty"` // Cached: "OPEN", "DRAFT", "MERGED", "CLOSED"
IsMerged bool `json:"is_merged,omitempty"`
IsRemote bool `json:"is_remote,omitempty"`
Remote string `json:"remote,omitempty"` // git remote to push to (e.g. fork remote); defaults to "origin"
// PreSyncCommit is the SHA the branch pointed at before the current sync run
// began rewriting it. Used as the `oldBase` for `git rebase --onto newParent
// oldBase` so children of a freshly-rebased parent don't replay the parent's
// commits and re-encounter conflicts that were already resolved upstream.
// Persisted across process boundaries so `ezs sync --continue` (a separate
// invocation) can use it. Cleared when the branch's sync completes cleanly;
// left set while a rebase/merge is in progress.
PreSyncCommit string `json:"pre_sync_commit,omitempty"`
// PreSyncCommitAt is the Unix epoch second at which PreSyncCommit was last
// recorded. Used by stale-snapshot cleanup to age out snapshots from prior
// runs that no longer have a worktree to introspect — without this, a
// crashed checkout-based sync would leave its snapshot in cache forever.
PreSyncCommitAt int64 `json:"pre_sync_commit_at,omitempty"`
// AgentSessionID is the UUID of the AI agent session bound to this branch
// in branch-scoped (`ezs agent --branch`) mode. Used to resume the same
// session on subsequent `ezs agent` runs against this branch.
AgentSessionID string `json:"agent_session_id,omitempty"`
// AgentSessionMode tags how the session was created. Branch-scoped sessions
// are always work-mode (feature mode requires a stack), so this is set to
// "work" on write and consumed by `ezs agent ls --feature` to filter rows.
// Empty on legacy entries written before mode tracking; treated as "work".
AgentSessionMode string `json:"agent_session_mode,omitempty"`
}
BranchCache holds cached metadata for a branch
func (*BranchCache) ClearPRFields ¶ added in v4.8.0
func (bc *BranchCache) ClearPRFields()
ClearPRFields zeroes the PR-association fields on this BranchCache while preserving worktree, fork-remote, and is_remote metadata. Used by `ezs pr unlink` and by recovery paths that detect a cached PR is no longer on GitHub.
type BranchTree ¶
type BranchTree map[string]BranchTree
BranchTree is a recursive map representing the stack hierarchy Each key is a branch name, and its value is another BranchTree of its children
type CacheConfig ¶
type CacheConfig struct {
Branches map[string]*BranchCache `json:"branches"`
// contains filtered or unexported fields
}
CacheConfig holds cached branch metadata for a repo
func LoadCacheConfig ¶
func LoadCacheConfig(repoDir string) (*CacheConfig, error)
LoadCacheConfig loads cached branch metadata. This now delegates to the combined stacks file. Kept for backward compatibility with callers that load cache separately.
func (*CacheConfig) GetBranchCache ¶
func (cc *CacheConfig) GetBranchCache(branchName string) *BranchCache
GetBranchCache returns cached metadata for a branch
func (*CacheConfig) Save ¶
func (cc *CacheConfig) Save(repoDir string) error
Save writes the cache data back to the combined stacks.json file under the per-process file lock and a three-way merge against any concurrent peer-process changes to the branch cache. Without the merge, a parallel `ezs sync` (which writes via StackConfig.Save / MutateBranchCache) could add a new branch entry between our load and save, and our wholesale- replace of `rd.Branches` would silently delete it.
Prefer MutateBranchCache for narrow updates — it scopes the RMW window to a single branch under the same lock and avoids carrying stale state. This path remains for callers that need to rewrite multiple entries together.
func (*CacheConfig) SetBranchCache ¶
func (cc *CacheConfig) SetBranchCache(branchName string, cache *BranchCache)
SetBranchCache sets cached metadata for a branch
type Config ¶
type Config struct {
DefaultBaseBranch string `json:"default_base_branch"`
GitHubToken string `json:"github_token,omitempty"`
Repos map[string]*RepoConfig `json:"repos"`
}
Config holds the global configuration for ezstack
func Load ¶
Load loads the configuration from ~/.ezstack/config.json. Top-level scalar values are resolved through Viper so that EZSTACK_-prefixed environment variables (e.g. EZSTACK_GITHUB_TOKEN) take precedence over the file. The repos map is read directly from JSON because Viper lowercases all keys, which would corrupt filesystem-path map keys like /Users/….
func (*Config) GetBaseBranch ¶
GetBaseBranch returns the base branch for a repo (repo-specific or global default)
func (*Config) GetCdAfterNew ¶
GetCdAfterNew returns whether to cd after creating a new worktree (default: true)
func (*Config) GetInitSubmodules ¶ added in v4.6.0
GetInitSubmodules returns whether to mirror the main worktree's initialized submodules into newly created worktrees (default: true).
func (*Config) GetRepoConfig ¶
func (c *Config) GetRepoConfig(repoPath string) *RepoConfig
GetRepoConfig returns the configuration for a specific repo path
func (*Config) GetSyncStrategy ¶
GetSyncStrategy returns the sync strategy for a repo ("rebase" or "merge", default "rebase")
func (*Config) GetUseWorktrees ¶
GetUseWorktrees returns whether to use worktrees for new branches (default: true)
func (*Config) GetWorktreeBaseDir ¶
GetWorktreeBaseDir returns the worktree base dir for a repo, or empty if not configured
func (*Config) Save ¶
Save persists the configuration to ~/.ezstack/config.json under the same per-process lock model used by StackConfig.Save: acquire flock, reload the on-disk file, merge our changes against any concurrent peer's changes, then atomicWriteFile.
Without this, two parallel `ezs config set …` runs (or any path that auto-saves the global config) silently lost the earlier writer's update. The merge is map-level (per-repo): if peer added or modified another repo's RepoConfig while we were holding `c` in memory, their entry survives. Same-repo concurrent edits resolve last-writer-wins because `c.Repos` doesn't carry a load-time snapshot — that's a documented limitation, not a regression: pre-PR, every save was last-writer-wins across the whole file.
func (*Config) SetRepoConfig ¶
func (c *Config) SetRepoConfig(repoPath string, repoCfg *RepoConfig)
SetRepoConfig sets the configuration for a specific repo
type RepoConfig ¶
type RepoConfig struct {
RepoPath string `json:"repo_path"`
WorktreeBaseDir string `json:"worktree_base_dir"`
DefaultBaseBranch string `json:"default_base_branch,omitempty"`
CdAfterNew *bool `json:"cd_after_new,omitempty"`
UseWorktrees *bool `json:"use_worktrees,omitempty"`
AutoDraftWipCommits *bool `json:"auto_draft_wip_commits,omitempty"`
InitSubmodules *bool `json:"init_submodules,omitempty"` // Mirror main worktree's initialized submodules into new worktrees (default: true)
SyncStrategy string `json:"sync_strategy,omitempty"` // "rebase" (default) or "merge"
AgentCommand string `json:"agent_command,omitempty"` // AI agent CLI command (default: "claude")
}
RepoConfig holds configuration for a specific repository
func (*RepoConfig) GetAgentCommand ¶
func (rc *RepoConfig) GetAgentCommand() string
GetAgentCommand returns the configured agent command, defaulting to "claude".
type Stack ¶
type Stack struct {
Hash string `json:"-"` // Populated from map key at load time
Name string `json:"name,omitempty"` // Optional user-given name for the stack
Root string `json:"root"` // The base branch (e.g. "main", or a remote branch name)
RootBase string `json:"root_base,omitempty"` // The branch the root's PR targets (for computing root diff)
RootPRNumber int `json:"-"` // Runtime-only: derived from RootPRUrl
RootPRUrl string `json:"root_pr_url,omitempty"` // PR URL of the root branch (for remote base branches)
RootIsRemote bool `json:"root_is_remote,omitempty"` // Root is a remote-tracked branch (set by RegisterRemoteBranch); drives the (remote) tag in PrintStack
DeleteDeclined bool `json:"delete_declined,omitempty"` // User declined cleanup prompt; don't re-ask
AgentSessionID string `json:"agent_session_id,omitempty"` // UUID of the AI agent session bound to this stack (used by `ezs agent` to resume)
AgentSessionMode string `json:"agent_session_mode,omitempty"` // Mode the session was created in: "work" or "feature". Empty ⇒ legacy entry, treated as "work".
Tree BranchTree `json:"tree"` // The tree of branches
Branches []*Branch `json:"-"` // Runtime-only: populated from Tree for backward compatibility
// contains filtered or unexported fields
}
Stack represents a chain of stacked branches as a tree Hash is the map key in StackConfig.Stacks and is populated at load time.
func (*Stack) AddSubtree ¶
func (s *Stack) AddSubtree(branchName string, subtree BranchTree, parentName string) bool
AddSubtree adds a branch with its subtree under a parent. Returns false if parentName was not found in the tree (non-root case).
func (*Stack) DisplayName ¶
DisplayName returns the display string for a stack: "name hash" or just hash
func (*Stack) ExtractSubtree ¶
func (s *Stack) ExtractSubtree(branchName string) BranchTree
ExtractSubtree removes a branch and its entire subtree from the stack and returns the subtree
func (*Stack) FindBranch ¶
FindBranch finds a branch in the tree and returns its parent name
func (*Stack) GetBranches ¶
func (s *Stack) GetBranches(cache *CacheConfig) []*Branch
GetBranches returns a flat list of branches from the tree structure Branches are returned in depth-first order with siblings sorted alphabetically The cache is used to populate metadata fields
func (*Stack) GetChildren ¶
GetChildren returns the immediate children of a branch
func (*Stack) IsFullyMerged ¶
func (s *Stack) IsFullyMerged(cache *CacheConfig) bool
IsFullyMerged returns true if every branch in the stack is marked as merged
func (*Stack) PopulateBranches ¶
func (s *Stack) PopulateBranches()
PopulateBranches rebuilds the Branches slice from the Tree structure This should be called after loading or after modifying the Tree
func (*Stack) PopulateBranchesWithCache ¶
func (s *Stack) PopulateBranchesWithCache(cache *CacheConfig)
PopulateBranchesWithCache rebuilds the Branches slice using the provided cache
func (*Stack) RemoveBranch ¶
RemoveBranch removes a branch from the stack tree If the branch has children, they are moved up to the branch's parent
func (*Stack) RenameBranchInTree ¶
RenameBranchInTree renames a branch in the tree, preserving its children and position
func (*Stack) ReparentBranch ¶
ReparentBranch moves a branch to be under a new parent If newParent is empty or matches the root, the branch becomes a root-level branch
type StackConfig ¶
type StackConfig struct {
Stacks map[string]*Stack `json:"stacks"`
Cache *CacheConfig `json:"-"` // loaded alongside stacks, not serialized separately
// contains filtered or unexported fields
}
StackConfig holds metadata about stacks for a single repo
func LoadStackConfig ¶
func LoadStackConfig(repoDir string) (*StackConfig, error)
LoadStackConfig loads stack metadata and branch cache for a specific repo from $HOME/.ezstack/stacks.json It handles migration from older formats using a versioned migration chain.
func (*StackConfig) Save ¶
func (sc *StackConfig) Save(repoDir string) error
Save saves the stack config and cache for this repo to $HOME/.ezstack/stacks.json
type SyncLock ¶ added in v4.8.0
type SyncLock struct {
// contains filtered or unexported fields
}
SyncLock acquires an exclusive advisory flock on a long-lived lock file for the duration of a sync run. Returns a release function that callers must call (typically via defer) when the run completes. Failure to acquire the lock (another ezstack process is already syncing) is returned as an error.
The lock file is `stacks.json.sync.lock` next to stacks.json. Because stacks.json itself is global per ezstack install (one file shared across all tracked repos), the lock is also global: concurrent syncs across different repos serialize. The lock is distinct from the per-Save lock (`stacks.json.lock`) so a sync's atomic saves don't block on themselves.