package projectsync import ( "errors" "fmt" "path/filepath" "strings" "github.com/nomadcode/nomadcode-core/internal/db" "github.com/nomadcode/nomadcode-core/internal/workitem" ) var ErrInvalidConfig = errors.New("invalid project sync config") const ( DefaultSlotIndex SlotIndex = 0 MaxSlotIndex SlotIndex = 999 ) type BranchName string const ( BranchNameMain BranchName = "main" BranchNameDevelop BranchName = "develop" ) // reservedBranchNames is the closed set of branch workspace names. Only these // branches get a fixed directory under branches/; agent slots use slots/NNN. var reservedBranchNames = map[BranchName]struct{}{ BranchNameMain: {}, BranchNameDevelop: {}, } func (b BranchName) Valid() bool { _, ok := reservedBranchNames[b] return ok } type BranchSyncState string const ( BranchSyncStateSynced BranchSyncState = "synced" BranchSyncStateStale BranchSyncState = "stale" BranchSyncStateError BranchSyncState = "error" ) func (s BranchSyncState) Valid() bool { switch s { case BranchSyncStateSynced, BranchSyncStateStale, BranchSyncStateError: return true default: return false } } // RequiredBranchWorkspaces returns the fixed branch names every project // workspace must provision under branches/. func RequiredBranchWorkspaces() []BranchName { return []BranchName{BranchNameMain, BranchNameDevelop} } // BranchWorkspacePath returns the filesystem path for a named branch workspace // under the project workspace root. Only reserved branch names are accepted; // agent slots must use SlotWorkspacePath instead. func BranchWorkspacePath(workspaceBasePath, repoDirName string, branch BranchName) (string, error) { if !branch.Valid() { return "", ErrInvalidConfig } root, err := ProjectWorkspaceRoot(workspaceBasePath, repoDirName) if err != nil { return "", err } return filepath.Join(root, "branches", string(branch)), nil } type ProviderProjectTarget struct { Provider workitem.ProviderID Tenant string Project string } type Config struct { Target ProviderProjectTarget GitRemoteURL string SourceBranch string WorkspaceID string WorkspaceBasePath string RepoDirName string } type SlotIndex int type SlotState string const ( SlotStateAvailable SlotState = "available" SlotStateInUse SlotState = "in_use" SlotStateDirty SlotState = "dirty" SlotStateError SlotState = "error" ) func (t ProviderProjectTarget) Normalize() (ProviderProjectTarget, error) { target := ProviderProjectTarget{ Provider: workitem.ProviderID(strings.TrimSpace(string(t.Provider))), Tenant: strings.TrimSpace(t.Tenant), Project: strings.TrimSpace(t.Project), } if target.Provider == "" || target.Tenant == "" || target.Project == "" { return ProviderProjectTarget{}, ErrInvalidConfig } return target, nil } func (c Config) Normalize() (Config, error) { target, err := c.Target.Normalize() if err != nil { return Config{}, err } basePath, err := normalizePath(c.WorkspaceBasePath) if err != nil { return Config{}, err } repoDir, err := normalizeRepoDirName(c.RepoDirName) if err != nil { return Config{}, err } config := Config{ Target: target, GitRemoteURL: strings.TrimSpace(c.GitRemoteURL), SourceBranch: strings.TrimSpace(c.SourceBranch), WorkspaceID: strings.TrimSpace(c.WorkspaceID), WorkspaceBasePath: basePath, RepoDirName: repoDir, } if config.GitRemoteURL == "" || config.SourceBranch == "" || config.WorkspaceID == "" { return Config{}, ErrInvalidConfig } return config, nil } func (c Config) ProjectWorkspaceRoot() (string, error) { config, err := c.Normalize() if err != nil { return "", err } return ProjectWorkspaceRoot(config.WorkspaceBasePath, config.RepoDirName) } func (c Config) SlotWorkspacePath(slot SlotIndex) (string, error) { config, err := c.Normalize() if err != nil { return "", err } return SlotWorkspacePath(config.WorkspaceBasePath, config.RepoDirName, slot) } func NewSlotIndex(index int) (SlotIndex, error) { slot := SlotIndex(index) if !slot.Valid() { return 0, ErrInvalidConfig } return slot, nil } func (s SlotIndex) Valid() bool { return s >= DefaultSlotIndex && s <= MaxSlotIndex } func (s SlotIndex) String() string { return fmt.Sprintf("%03d", int(s)) } func (s SlotIndex) PathName() (string, error) { if !s.Valid() { return "", ErrInvalidConfig } return s.String(), nil } func (s SlotState) Valid() bool { switch s { case SlotStateAvailable, SlotStateInUse, SlotStateDirty, SlotStateError: return true default: return false } } func (s SlotState) Allocatable() bool { return s == SlotStateAvailable } func ProjectWorkspaceRoot(workspaceBasePath, repoDirName string) (string, error) { basePath, err := normalizePath(workspaceBasePath) if err != nil { return "", err } repoDir, err := normalizeRepoDirName(repoDirName) if err != nil { return "", err } return filepath.Join(basePath, repoDir), nil } func SlotWorkspacePath(workspaceBasePath, repoDirName string, slot SlotIndex) (string, error) { slotName, err := slot.PathName() if err != nil { return "", err } root, err := ProjectWorkspaceRoot(workspaceBasePath, repoDirName) if err != nil { return "", err } return filepath.Join(root, "slots", slotName), nil } func normalizePath(value string) (string, error) { trimmed := strings.TrimSpace(value) if trimmed == "" { return "", ErrInvalidConfig } cleaned := filepath.Clean(trimmed) if cleaned == "." { return "", ErrInvalidConfig } return cleaned, nil } func normalizeRepoDirName(value string) (string, error) { trimmed := strings.TrimSpace(value) if trimmed == "" { return "", ErrInvalidConfig } cleaned := filepath.Clean(trimmed) if cleaned == "." || cleaned == ".." || filepath.IsAbs(cleaned) || strings.ContainsAny(cleaned, `/\`) { return "", ErrInvalidConfig } return cleaned, nil } func (c Config) ToCreateProjectSyncParams() db.CreateProjectSyncSettingParams { return db.CreateProjectSyncSettingParams{ Provider: string(c.Target.Provider), Tenant: c.Target.Tenant, Project: c.Target.Project, GitRemoteUrl: c.GitRemoteURL, SourceBranch: c.SourceBranch, WorkspaceID: c.WorkspaceID, WorkspaceBasePath: c.WorkspaceBasePath, RepoDirName: c.RepoDirName, } } // WorkspaceSlot is the typed view of a persisted workspace slot row, keeping // the slot index and state as projectsync domain types. type WorkspaceSlot struct { ID int64 ProjectSyncSettingID int64 Index SlotIndex State SlotState Path string } // SlotFromDBRecord converts a generated db row into the typed WorkspaceSlot. func SlotFromDBRecord(r db.WorkspaceSlot) WorkspaceSlot { return WorkspaceSlot{ ID: r.ID, ProjectSyncSettingID: r.ProjectSyncSettingID, Index: SlotIndex(r.SlotIndex), State: SlotState(r.State), Path: r.Path, } } // ToUpsertWorkspaceSlotParams builds DB upsert params for a slot, rejecting an // out-of-range index before it reaches the database. func ToUpsertWorkspaceSlotParams(projectSyncSettingID int64, index SlotIndex, path string) (db.UpsertWorkspaceSlotParams, error) { if !index.Valid() { return db.UpsertWorkspaceSlotParams{}, ErrInvalidConfig } return db.UpsertWorkspaceSlotParams{ ProjectSyncSettingID: projectSyncSettingID, SlotIndex: int32(index), Path: path, }, nil } // ToUpdateWorkspaceSlotStateParams builds DB params for a slot state // transition, rejecting unknown states to mirror the DB check constraint. func ToUpdateWorkspaceSlotStateParams(id int64, state SlotState) (db.UpdateWorkspaceSlotStateParams, error) { if !state.Valid() { return db.UpdateWorkspaceSlotStateParams{}, ErrInvalidConfig } return db.UpdateWorkspaceSlotStateParams{ ID: id, State: string(state), }, nil } func ConfigFromDBRecord(r db.ProjectSyncSetting) Config { return Config{ Target: ProviderProjectTarget{ Provider: workitem.ProviderID(r.Provider), Tenant: r.Tenant, Project: r.Project, }, GitRemoteURL: r.GitRemoteUrl, SourceBranch: r.SourceBranch, WorkspaceID: r.WorkspaceID, WorkspaceBasePath: r.WorkspaceBasePath, RepoDirName: r.RepoDirName, } }