config

package
v4.8.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: May 8, 2026 License: MIT Imports: 14 Imported by: 0

Documentation

Index

Constants

View Source
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.

View Source
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

View Source
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.

View Source
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.

View Source
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.

View Source
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.

View Source
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

func ConfigDir() (string, error)

ConfigDir returns the path to the ezstack config directory. Checks EZSTACK_HOME first, then defaults to $HOME/.ezstack.

func GenerateStackHash

func GenerateStackHash(name string) string

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

func PRNumberFromURL(url string) int

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

func SortBranchesTopologically(branches []*Branch) []*Branch

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) CanPush

func (b *Branch) CanPush() bool

CanPush returns true if push operations are allowed for this branch.

func (*Branch) EffectiveRemote

func (b *Branch) EffectiveRemote() string

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

func Load() (*Config, error)

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

func (c *Config) GetBaseBranch(repoPath string) string

GetBaseBranch returns the base branch for a repo (repo-specific or global default)

func (*Config) GetCdAfterNew

func (c *Config) GetCdAfterNew(repoPath string) bool

GetCdAfterNew returns whether to cd after creating a new worktree (default: true)

func (*Config) GetInitSubmodules added in v4.6.0

func (c *Config) GetInitSubmodules(repoPath string) bool

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

func (c *Config) GetSyncStrategy(repoPath string) string

GetSyncStrategy returns the sync strategy for a repo ("rebase" or "merge", default "rebase")

func (*Config) GetUseWorktrees

func (c *Config) GetUseWorktrees(repoPath string) bool

GetUseWorktrees returns whether to use worktrees for new branches (default: true)

func (*Config) GetWorktreeBaseDir

func (c *Config) GetWorktreeBaseDir(repoPath string) string

GetWorktreeBaseDir returns the worktree base dir for a repo, or empty if not configured

func (*Config) Save

func (c *Config) Save() error

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) AddBranch

func (s *Stack) AddBranch(branchName, parentName string)

AddBranch adds a branch to the stack tree under the specified parent

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

func (s *Stack) DisplayName() string

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

func (s *Stack) FindBranch(branchName string) (parent string, found bool)

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

func (s *Stack) GetChildren(branchName string) []string

GetChildren returns the immediate children of a branch

func (*Stack) HasBranch

func (s *Stack) HasBranch(branchName string) bool

HasBranch returns true if the branch exists in the stack

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

func (s *Stack) RemoveBranch(branchName string)

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

func (s *Stack) RenameBranchInTree(oldName, newName string) bool

RenameBranchInTree renames a branch in the tree, preserving its children and position

func (*Stack) ReparentBranch

func (s *Stack) ReparentBranch(branchName, newParent string)

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.

func AcquireSyncLock added in v4.8.0

func AcquireSyncLock(stacksJSONPath string) (*SyncLock, error)

func (*SyncLock) Release added in v4.8.0

func (s *SyncLock) Release()

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL