From a3763f3905bf58a0c396043907684e5237f4082e Mon Sep 17 00:00:00 2001 From: Jahvon Dockery Date: Mon, 13 Apr 2026 21:06:30 -0400 Subject: [PATCH 1/6] feat: Add git workspace support with clone, update, and force pull Add support for Git repositories as workspaces. Users can now add workspaces directly from HTTPS/SSH Git URLs with --branch and --tag flags. Implements workspace update command to pull latest changes, and extends flow sync with --git flag to update all git workspaces. Includes --force flag for hard reset when local changes conflict. Closes #138 Co-Authored-By: Claude Opus 4.6 (1M context) --- cmd/internal/flags/types.go | 28 ++++ cmd/internal/sync.go | 59 ++++++- cmd/internal/workspace.go | 180 +++++++++++++++++--- docs/cli/flow_sync.md | 8 +- docs/cli/flow_workspace.md | 3 +- docs/cli/flow_workspace_add.md | 20 ++- docs/cli/flow_workspace_update.md | 33 ++++ docs/guides/workspaces.md | 65 ++++++- docs/public/schemas/workspace_schema.json | 20 +++ docs/types/workspace.md | 3 + internal/services/git/git.go | 196 ++++++++++++++++++++-- types/workspace/schema.yaml | 19 +++ types/workspace/workspace.gen.go | 23 +++ 13 files changed, 603 insertions(+), 54 deletions(-) create mode 100644 docs/cli/flow_workspace_update.md diff --git a/cmd/internal/flags/types.go b/cmd/internal/flags/types.go index fd4f2737..1095ba76 100644 --- a/cmd/internal/flags/types.go +++ b/cmd/internal/flags/types.go @@ -111,6 +111,34 @@ var SetAfterCreateFlag = &Metadata{ Required: false, } +var GitBranchFlag = &Metadata{ + Name: "branch", + Shorthand: "b", + Usage: "Git branch to checkout when cloning a git workspace", + Default: "", + Required: false, +} + +var GitTagFlag = &Metadata{ + Name: "tag", + Usage: "Git tag to checkout when cloning a git workspace", + Default: "", +} + +var GitPullFlag = &Metadata{ + Name: "git", + Shorthand: "g", + Usage: "Pull latest changes for all git-sourced workspaces before syncing", + Default: false, + Required: false, +} + +var ForceFlag = &Metadata{ + Name: "force", + Usage: "Force update by discarding local changes (hard reset to remote)", + Default: false, +} + var FixedWsModeFlag = &Metadata{ Name: "fixed", Shorthand: "f", diff --git a/cmd/internal/sync.go b/cmd/internal/sync.go index 27bfed60..1ebc1ada 100644 --- a/cmd/internal/sync.go +++ b/cmd/internal/sync.go @@ -6,8 +6,11 @@ import ( "github.com/spf13/cobra" + "github.com/flowexec/flow/cmd/internal/flags" + "github.com/flowexec/flow/internal/services/git" "github.com/flowexec/flow/pkg/cache" "github.com/flowexec/flow/pkg/context" + "github.com/flowexec/flow/pkg/filesystem" "github.com/flowexec/flow/pkg/logger" ) @@ -15,7 +18,10 @@ func RegisterSyncCmd(ctx *context.Context, rootCmd *cobra.Command) { subCmd := &cobra.Command{ Use: "sync", Short: "Refresh workspace cache and discover new executables.", - Args: cobra.NoArgs, + Long: "Refresh the workspace cache and discover new executables. " + + "Use --git to also pull latest changes for all git-sourced workspaces before syncing. " + + "Use --force with --git to discard local changes and hard reset to the remote.", + Args: cobra.NoArgs, PreRun: func(cmd *cobra.Command, args []string) { printContext(ctx, cmd) }, @@ -23,14 +29,63 @@ func RegisterSyncCmd(ctx *context.Context, rootCmd *cobra.Command) { syncFunc(ctx, cmd, args) }, } + RegisterFlag(ctx, subCmd, *flags.GitPullFlag) + RegisterFlag(ctx, subCmd, *flags.ForceFlag) rootCmd.AddCommand(subCmd) } -func syncFunc(ctx *context.Context, _ *cobra.Command, _ []string) { +func syncFunc(ctx *context.Context, cmd *cobra.Command, _ []string) { + pullGit := flags.ValueFor[bool](cmd, *flags.GitPullFlag, false) + force := flags.ValueFor[bool](cmd, *flags.ForceFlag, false) + + if force && !pullGit { + logger.Log().Fatalf("--force can only be used with --git") + } + start := time.Now() + + if pullGit { + pullGitWorkspaces(ctx, force) + } + if err := cache.UpdateAll(ctx.DataStore); err != nil { logger.Log().FatalErr(err) } duration := time.Since(start) logger.Log().PlainTextSuccess(fmt.Sprintf("Synced flow cache (%s)", duration.Round(time.Second))) } + +func pullGitWorkspaces(ctx *context.Context, force bool) { + cfg := ctx.Config + for name, path := range cfg.Workspaces { + wsCfg, err := filesystem.LoadWorkspaceConfig(name, path) + if err != nil { + logger.Log().Warnf("Skipping workspace '%s': %v", name, err) + continue + } + if wsCfg.GitRemote == "" { + continue + } + + logger.Log().Infof("Pulling workspace '%s' from %s...", name, wsCfg.GitRemote) + pullStart := time.Now() + + var pullErr error + if force { + pullErr = git.ResetPull(path, wsCfg.GitRef, string(wsCfg.GitRefType)) + } else { + pullErr = git.Pull(path, wsCfg.GitRef, string(wsCfg.GitRefType)) + } + + if pullErr != nil { + logger.Log().Errorf("Failed to pull workspace '%s': %v", name, pullErr) + if !force { + logger.Log().Warnf("Hint: use --force to discard local changes and hard reset to remote") + } + continue + } + + pullDuration := time.Since(pullStart) + logger.Log().Infof("Workspace '%s' updated (%s)", name, pullDuration.Round(time.Millisecond)) + } +} diff --git a/cmd/internal/workspace.go b/cmd/internal/workspace.go index 2764156e..80e24f96 100644 --- a/cmd/internal/workspace.go +++ b/cmd/internal/workspace.go @@ -14,6 +14,7 @@ import ( "github.com/flowexec/flow/cmd/internal/flags" workspaceIO "github.com/flowexec/flow/internal/io/workspace" + "github.com/flowexec/flow/internal/services/git" "github.com/flowexec/flow/pkg/cache" "github.com/flowexec/flow/pkg/context" "github.com/flowexec/flow/pkg/filesystem" @@ -30,6 +31,7 @@ func RegisterWorkspaceCmd(ctx *context.Context, rootCmd *cobra.Command) { Short: "Manage development workspaces.", } registerAddWorkspaceCmd(ctx, wsCmd) + registerUpdateWorkspaceCmd(ctx, wsCmd) registerSwitchWorkspaceCmd(ctx, wsCmd) registerRemoveWorkspaceCmd(ctx, wsCmd) registerListWorkspaceCmd(ctx, wsCmd) @@ -39,78 +41,208 @@ func RegisterWorkspaceCmd(ctx *context.Context, rootCmd *cobra.Command) { func registerAddWorkspaceCmd(ctx *context.Context, wsCmd *cobra.Command) { createCmd := &cobra.Command{ - Use: "add NAME PATH", + Use: "add NAME PATH_OR_GIT_URL", Aliases: []string{"init", "create", "new"}, - Short: "Initialize a new workspace.", - Args: cobra.ExactArgs(2), - Run: func(cmd *cobra.Command, args []string) { addWorkspaceFunc(ctx, cmd, args) }, + Short: "Initialize a new workspace from a local path or Git URL.", + Long: "Initialize a new workspace. PATH_OR_GIT_URL can be a local directory path " + + "or a Git repository URL (HTTPS or SSH). When a Git URL is provided, " + + "the repository is cloned to the flow cache directory and registered as a workspace.\n\n" + + "Examples:\n" + + " flow workspace add my-ws ./path/to/dir\n" + + " flow workspace add shared https://github.com/org/flows.git\n" + + " flow workspace add tools git@github.com:org/tools.git --branch main\n" + + " flow workspace add stable https://github.com/org/flows.git --tag v1.0.0", + Args: cobra.ExactArgs(2), + Run: func(cmd *cobra.Command, args []string) { addWorkspaceFunc(ctx, cmd, args) }, } RegisterFlag(ctx, createCmd, *flags.SetAfterCreateFlag) + RegisterFlag(ctx, createCmd, *flags.GitBranchFlag) + RegisterFlag(ctx, createCmd, *flags.GitTagFlag) wsCmd.AddCommand(createCmd) } func addWorkspaceFunc(ctx *context.Context, cmd *cobra.Command, args []string) { name := args[0] - path := args[1] + pathOrURL := args[1] userConfig := ctx.Config if _, found := userConfig.Workspaces[name]; found { logger.Log().Fatalf("workspace %s already exists at %s", name, userConfig.Workspaces[name]) } + branch := flags.ValueFor[string](cmd, *flags.GitBranchFlag, false) + tag := flags.ValueFor[string](cmd, *flags.GitTagFlag, false) + if branch != "" && tag != "" { + logger.Log().Fatalf("cannot specify both --branch and --tag") + } + + var path string + if git.IsGitURL(pathOrURL) { + clonePath, err := git.ClonePath(pathOrURL) + if err != nil { + logger.Log().FatalErr(errors.Wrap(err, "unable to determine clone path")) + } + + logger.Log().Infof("Cloning %s...", pathOrURL) + if err := git.Clone(pathOrURL, clonePath, branch, tag); err != nil { + logger.Log().FatalErr(errors.Wrap(err, "unable to clone git repository")) + } + path = clonePath + + // Write git metadata to workspace config after clone + wsCfg := &workspace.Workspace{} + if filesystem.WorkspaceConfigExists(path) { + wsCfg, err = filesystem.LoadWorkspaceConfig(name, path) + if err != nil { + logger.Log().FatalErr(errors.Wrap(err, "unable to load cloned workspace config")) + } + } else { + wsCfg = workspace.DefaultWorkspaceConfig(name) + } + wsCfg.GitRemote = pathOrURL + if branch != "" { + wsCfg.GitRef = branch + wsCfg.GitRefType = workspace.WorkspaceGitRefTypeBranch + } else if tag != "" { + wsCfg.GitRef = tag + wsCfg.GitRefType = workspace.WorkspaceGitRefTypeTag + } + if err := filesystem.WriteWorkspaceConfig(path, wsCfg); err != nil { + logger.Log().FatalErr(errors.Wrap(err, "unable to write workspace config with git metadata")) + } + } else { + if branch != "" || tag != "" { + logger.Log().Fatalf("--branch and --tag flags are only supported with Git URLs") + } + path = resolveLocalPath(pathOrURL, name) + if !filesystem.WorkspaceConfigExists(path) { + if err := filesystem.InitWorkspaceConfig(name, path); err != nil { + logger.Log().FatalErr(err) + } + } + } + + userConfig.Workspaces[name] = path + + set := flags.ValueFor[bool](cmd, *flags.SetAfterCreateFlag, false) + if set { + userConfig.CurrentWorkspace = name + logger.Log().Infof("Workspace '%s' set as current workspace", name) + } + + if err := filesystem.WriteConfig(userConfig); err != nil { + logger.Log().FatalErr(err) + } + + if err := cache.UpdateAll(ctx.DataStore); err != nil { + logger.Log().FatalErr(errors.Wrap(err, "failure updating cache")) + } + + logger.Log().PlainTextSuccess(fmt.Sprintf("Workspace '%s' created in %s", name, path)) +} + +func resolveLocalPath(path, name string) string { switch { case path == "": - path = filepath.Join(filesystem.CachedDataDirPath(), name) + return filepath.Join(filesystem.CachedDataDirPath(), name) case path == "." || strings.HasPrefix(path, "./"): wd, err := os.Getwd() if err != nil { logger.Log().FatalErr(err) } if path == "." { - path = wd - } else { - path = fmt.Sprintf("%s/%s", wd, path[2:]) + return wd } + return fmt.Sprintf("%s/%s", wd, path[2:]) case path == "~" || strings.HasPrefix(path, "~/"): hd, err := os.UserHomeDir() if err != nil { logger.Log().FatalErr(err) } if path == "~" { - path = hd - } else { - path = fmt.Sprintf("%s/%s", hd, path[2:]) + return hd } + return fmt.Sprintf("%s/%s", hd, path[2:]) case !filepath.IsAbs(path): wd, err := os.Getwd() if err != nil { logger.Log().FatalErr(err) } - path = fmt.Sprintf("%s/%s", wd, path) + return fmt.Sprintf("%s/%s", wd, path) + default: + return path } +} - if !filesystem.WorkspaceConfigExists(path) { - if err := filesystem.InitWorkspaceConfig(name, path); err != nil { - logger.Log().FatalErr(err) +func registerUpdateWorkspaceCmd(ctx *context.Context, wsCmd *cobra.Command) { + updateCmd := &cobra.Command{ + Use: "update [NAME]", + Aliases: []string{"pull", "sync"}, + Short: "Pull latest changes for a git-sourced workspace.", + Long: "Pull the latest changes from the git remote for a workspace that was added from a Git URL. " + + "If NAME is omitted, the current workspace is used.\n\n" + + "This respects the branch or tag that was originally specified when the workspace was added.\n" + + "Use --force to discard local changes and hard reset to the remote.", + Args: cobra.MaximumNArgs(1), + ValidArgsFunction: func(_ *cobra.Command, _ []string, _ string) ([]cobra.Completion, cobra.ShellCompDirective) { + return maps.Keys(ctx.Config.Workspaces), cobra.ShellCompDirectiveNoFileComp + }, + Run: func(cmd *cobra.Command, args []string) { updateWorkspaceFunc(ctx, cmd, args) }, + } + RegisterFlag(ctx, updateCmd, *flags.ForceFlag) + wsCmd.AddCommand(updateCmd) +} + +func updateWorkspaceFunc(ctx *context.Context, cmd *cobra.Command, args []string) { + var workspaceName, wsPath string + if len(args) == 1 { + workspaceName = args[0] + wsPath = ctx.Config.Workspaces[workspaceName] + if wsPath == "" { + logger.Log().Fatalf("workspace %s not found", workspaceName) + } + } else { + if ctx.CurrentWorkspace == nil { + logger.Log().Fatalf("no current workspace set") } + workspaceName = ctx.CurrentWorkspace.AssignedName() + wsPath = ctx.CurrentWorkspace.Location() } - userConfig.Workspaces[name] = path - set := flags.ValueFor[bool](cmd, *flags.SetAfterCreateFlag, false) - if set { - userConfig.CurrentWorkspace = name - logger.Log().Infof("Workspace '%s' set as current workspace", name) + force := flags.ValueFor[bool](cmd, *flags.ForceFlag, false) + + wsCfg, err := filesystem.LoadWorkspaceConfig(workspaceName, wsPath) + if err != nil { + logger.Log().FatalErr(errors.Wrap(err, "unable to load workspace config")) } - if err := filesystem.WriteConfig(userConfig); err != nil { - logger.Log().FatalErr(err) + if wsCfg.GitRemote == "" { + logger.Log().Fatalf("workspace '%s' is not a git-sourced workspace (no gitRemote set in flow.yaml)", workspaceName) + } + + if force { + logger.Log().Warnf("Force updating workspace '%s' from %s (local changes will be discarded)...", workspaceName, wsCfg.GitRemote) + } else { + logger.Log().Infof("Updating workspace '%s' from %s...", workspaceName, wsCfg.GitRemote) + } + + if force { + err = git.ResetPull(wsPath, wsCfg.GitRef, string(wsCfg.GitRefType)) + } else { + err = git.Pull(wsPath, wsCfg.GitRef, string(wsCfg.GitRefType)) + } + if err != nil { + if !force { + logger.Log().Warnf("Hint: use --force to discard local changes and hard reset to remote") + } + logger.Log().FatalErr(errors.Wrap(err, "unable to update workspace")) } if err := cache.UpdateAll(ctx.DataStore); err != nil { logger.Log().FatalErr(errors.Wrap(err, "failure updating cache")) } - logger.Log().PlainTextSuccess(fmt.Sprintf("Workspace '%s' created in %s", name, path)) + logger.Log().PlainTextSuccess(fmt.Sprintf("Workspace '%s' updated", workspaceName)) } func registerSwitchWorkspaceCmd(ctx *context.Context, setCmd *cobra.Command) { diff --git a/docs/cli/flow_sync.md b/docs/cli/flow_sync.md index 8a177071..e70ad8f4 100644 --- a/docs/cli/flow_sync.md +++ b/docs/cli/flow_sync.md @@ -2,6 +2,10 @@ Refresh workspace cache and discover new executables. +### Synopsis + +Refresh the workspace cache and discover new executables. Use --git to also pull latest changes for all git-sourced workspaces before syncing. Use --force with --git to discard local changes and hard reset to the remote. + ``` flow sync [flags] ``` @@ -9,7 +13,9 @@ flow sync [flags] ### Options ``` - -h, --help help for sync + --force Force update by discarding local changes (hard reset to remote) + -g, --git Pull latest changes for all git-sourced workspaces before syncing + -h, --help help for sync ``` ### Options inherited from parent commands diff --git a/docs/cli/flow_workspace.md b/docs/cli/flow_workspace.md index 41f43386..905db240 100644 --- a/docs/cli/flow_workspace.md +++ b/docs/cli/flow_workspace.md @@ -18,9 +18,10 @@ Manage development workspaces. ### SEE ALSO * [flow](flow.md) - flow is a command line interface designed to make managing and running development workflows easier. -* [flow workspace add](flow_workspace_add.md) - Initialize a new workspace. +* [flow workspace add](flow_workspace_add.md) - Initialize a new workspace from a local path or Git URL. * [flow workspace get](flow_workspace_get.md) - Get workspace details. If the name is omitted, the current workspace is used. * [flow workspace list](flow_workspace_list.md) - List all registered workspaces. * [flow workspace remove](flow_workspace_remove.md) - Remove an existing workspace. * [flow workspace switch](flow_workspace_switch.md) - Switch the current workspace. +* [flow workspace update](flow_workspace_update.md) - Pull latest changes for a git-sourced workspace. diff --git a/docs/cli/flow_workspace_add.md b/docs/cli/flow_workspace_add.md index 93b5a55d..485344b5 100644 --- a/docs/cli/flow_workspace_add.md +++ b/docs/cli/flow_workspace_add.md @@ -1,16 +1,28 @@ ## flow workspace add -Initialize a new workspace. +Initialize a new workspace from a local path or Git URL. + +### Synopsis + +Initialize a new workspace. PATH_OR_GIT_URL can be a local directory path or a Git repository URL (HTTPS or SSH). When a Git URL is provided, the repository is cloned to the flow cache directory and registered as a workspace. + +Examples: + flow workspace add my-ws ./path/to/dir + flow workspace add shared https://github.com/org/flows.git + flow workspace add tools git@github.com:org/tools.git --branch main + flow workspace add stable https://github.com/org/flows.git --tag v1.0.0 ``` -flow workspace add NAME PATH [flags] +flow workspace add NAME PATH_OR_GIT_URL [flags] ``` ### Options ``` - -h, --help help for add - -s, --set Set the newly created workspace as the current workspace + -b, --branch string Git branch to checkout when cloning a git workspace + -h, --help help for add + -s, --set Set the newly created workspace as the current workspace + --tag string Git tag to checkout when cloning a git workspace ``` ### Options inherited from parent commands diff --git a/docs/cli/flow_workspace_update.md b/docs/cli/flow_workspace_update.md new file mode 100644 index 00000000..db92e680 --- /dev/null +++ b/docs/cli/flow_workspace_update.md @@ -0,0 +1,33 @@ +## flow workspace update + +Pull latest changes for a git-sourced workspace. + +### Synopsis + +Pull the latest changes from the git remote for a workspace that was added from a Git URL. If NAME is omitted, the current workspace is used. + +This respects the branch or tag that was originally specified when the workspace was added. +Use --force to discard local changes and hard reset to the remote. + +``` +flow workspace update [NAME] [flags] +``` + +### Options + +``` + --force Force update by discarding local changes (hard reset to remote) + -h, --help help for update +``` + +### Options inherited from parent commands + +``` + -L, --log-level string Log verbosity level (debug, info, fatal) (default "info") + --sync Sync flow cache and workspaces +``` + +### SEE ALSO + +* [flow workspace](flow_workspace.md) - Manage development workspaces. + diff --git a/docs/guides/workspaces.md b/docs/guides/workspaces.md index 7eca2838..b14c5562 100644 --- a/docs/guides/workspaces.md +++ b/docs/guides/workspaces.md @@ -25,6 +25,55 @@ flow workspace add my-project /path/to/project --set When you add a workspace, flow creates a `flow.yaml` configuration file in the root directory if one doesn't exist. +#### Git Workspaces + +You can also add workspaces directly from Git repositories. Flow clones the repository to its cache directory and registers it as a workspace: + +```shell +# Clone from HTTPS URL +flow workspace add shared-tools https://github.com/myorg/tools.git + +# Clone from SSH URL +flow workspace add k8s-flows git@github.com:platform/k8s.git --set + +# Clone a specific branch +flow workspace add dev-tools https://github.com/myorg/tools.git --branch develop + +# Clone a specific tag +flow workspace add stable https://github.com/myorg/tools.git --tag v1.0.0 +``` + +Git workspaces are stored in `~/.cache/flow/git-workspaces/` following Go module conventions (e.g., `github.com/myorg/tools/`). The git remote URL and branch/tag information are saved in the workspace's `flow.yaml` so they can be used for updates. + +### Updating Git Workspaces + +Pull the latest changes for a git-sourced workspace: + +```shell +# Update a specific workspace +flow workspace update shared-tools + +# Update the current workspace +flow workspace update + +# Force update, discarding any local changes +flow workspace update shared-tools --force +``` + +This respects the branch or tag originally specified when the workspace was added. For branch-based workspaces, it performs a `git pull`. For tag-based workspaces, it fetches the latest tags and checks out the specified tag. + +If a pull fails due to merge conflicts or local changes, the error output from git is shown directly. Use `--force` to discard local changes and hard reset to the remote state. + +You can also update all git workspaces at once during a cache sync: + +```shell +# Sync cache and pull all git workspaces +flow sync --git + +# Force pull all git workspaces (discards local changes) +flow sync --git --force +``` + ### Switching Workspaces Change your current workspace: @@ -111,6 +160,11 @@ executables: - `verbAliases`: Customize which verb synonyms are available - `envFiles`: List of environment files to load for all executables (the root `.env` is loaded by default) +**Git Workspace Fields** (set automatically when adding from a Git URL): +- `gitRemote`: The git remote URL for the workspace +- `gitRef`: The branch or tag name specified at registration +- `gitRefType`: Either `branch` or `tag` + > **Complete reference**: See the [workspace configuration schema](../types/workspace.md) for all available options. ## Workspace Modes @@ -161,14 +215,17 @@ executables: ### Shared Workspaces -Create workspaces for shared tools and utilities: +Share workspaces across teams using Git repositories: ```shell -# Create shared workspace -flow workspace add shared-tools ~/shared +# Add shared workspace from git +flow workspace add team-tools https://github.com/myorg/flow-workflows.git + +# Keep it up to date +flow workspace update team-tools # Reference from other workspaces -flow send shared-tools/slack:notification "Deployment complete" +flow send team-tools/slack:notification "Deployment complete" ``` ## What's Next? diff --git a/docs/public/schemas/workspace_schema.json b/docs/public/schemas/workspace_schema.json index 5dc17814..2f6bb7cc 100644 --- a/docs/public/schemas/workspace_schema.json +++ b/docs/public/schemas/workspace_schema.json @@ -71,6 +71,26 @@ "executables": { "$ref": "#/definitions/ExecutableFilter" }, + "gitRef": { + "description": "The git ref (branch or tag name) that was specified when the workspace was added from a git URL.\nUsed by `flow workspace update` to checkout the correct ref after pulling.\n", + "type": "string", + "default": "" + }, + "gitRefType": { + "description": "The type of git ref specified when the workspace was added.\nEither \"branch\" or \"tag\". Empty if no ref was specified.\n", + "type": "string", + "default": "", + "enum": [ + "", + "branch", + "tag" + ] + }, + "gitRemote": { + "description": "The git remote URL for the workspace. This is set automatically when a workspace is added from a git URL.\nUsed by `flow workspace update` to pull the latest changes.\n", + "type": "string", + "default": "" + }, "tags": { "$ref": "#/definitions/CommonTags", "default": [] diff --git a/docs/types/workspace.md b/docs/types/workspace.md index 8087da01..c63851fb 100644 --- a/docs/types/workspace.md +++ b/docs/types/workspace.md @@ -23,6 +23,9 @@ Every workspace has a workspace config file named `flow.yaml` in the root of the | `displayName` | The display name of the workspace. This is used in the interactive UI. | `string` | | | | `envFiles` | A list of environment variable files to load for the workspace. These files should contain key-value pairs of environment variables. By default, the `.env` file in the workspace root is loaded if it exists. | `array` (`string`) | [] | | | `executables` | | [ExecutableFilter](#executablefilter) | | | +| `gitRef` | The git ref (branch or tag name) that was specified when the workspace was added from a git URL. Used by `flow workspace update` to checkout the correct ref after pulling. | `string` | | | +| `gitRefType` | The type of git ref specified when the workspace was added. Either "branch" or "tag". Empty if no ref was specified. | `string` | | | +| `gitRemote` | The git remote URL for the workspace. This is set automatically when a workspace is added from a git URL. Used by `flow workspace update` to pull the latest changes. | `string` | | | | `tags` | | [CommonTags](#commontags) | [] | | | `verbAliases` | | [VerbAliases](#verbaliases) | | | diff --git a/internal/services/git/git.go b/internal/services/git/git.go index 0f6e9b6d..551c45bf 100644 --- a/internal/services/git/git.go +++ b/internal/services/git/git.go @@ -1,20 +1,180 @@ package git -// var log = io.Log().With().Str("service", "git").Logger() -// -// func Pull(repoDir string) error { -// if info, err := os.Stat(repoDir); err != nil && os.IsNotExist(err) { -// return fmt.Errorf("git repo %s does not exist", repoDir) -// } else if err != nil { -// return fmt.Errorf("unable to check for git repo %s - %w", repoDir, err) -// } else if !info.IsDir() { -// return fmt.Errorf("git repo %s is not a directory", repoDir) -// } -// -// if err := run.RunCmd("git pull", repoDir, nil); err != nil { -// return fmt.Errorf("unable to pull git repo %s - %w", repoDir, err) -// } -// -// log.Info().Msgf("successfully pulled git repo %s", repoDir) -// return nil -// } +import ( + "bytes" + "fmt" + "io" + "net/url" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + + "github.com/pkg/errors" + + "github.com/flowexec/flow/pkg/filesystem" +) + +var sshURLPattern = regexp.MustCompile(`^[\w.-]+@[\w.-]+:[\w./-]+$`) + +// IsGitURL returns true if the given string looks like a Git remote URL (HTTPS or SSH). +func IsGitURL(s string) bool { + if sshURLPattern.MatchString(s) { + return true + } + u, err := url.Parse(s) + if err != nil { + return false + } + return (u.Scheme == "https" || u.Scheme == "http" || u.Scheme == "ssh") && + u.Host != "" && + strings.HasSuffix(u.Path, ".git") +} + +// ClonePath returns the local directory path where a git workspace should be cloned. +// Follows Go module conventions: ~/.cache/flow/git-workspaces// +func ClonePath(gitURL string) (string, error) { + host, repoPath, err := parseGitURL(gitURL) + if err != nil { + return "", err + } + repoPath = strings.TrimSuffix(repoPath, ".git") + repoPath = strings.TrimPrefix(repoPath, "/") + return filepath.Join(filesystem.CachedDataDirPath(), "git-workspaces", host, repoPath), nil +} + +// Clone clones a git repository to the target directory. +// If branch is non-empty, it checks out that branch. +// If tag is non-empty, it checks out that tag. +// Progress output goes to stderr. +func Clone(gitURL, targetDir, branch, tag string) error { + if _, err := os.Stat(targetDir); err == nil { + entries, readErr := os.ReadDir(targetDir) + if readErr == nil && len(entries) > 0 { + return fmt.Errorf("target directory %s already exists and is not empty", targetDir) + } + } + + args := []string{"clone", "--progress"} + if branch != "" { + args = append(args, "--branch", branch) + } else if tag != "" { + args = append(args, "--branch", tag) + } + args = append(args, gitURL, targetDir) + + if err := runGit("", args...); err != nil { + return err + } + return nil +} + +// Pull fetches and pulls the latest changes for a git repository. +// If the workspace was cloned with a specific branch, it pulls that branch. +// If it was cloned with a tag, it fetches tags and checks out the tag. +func Pull(repoDir, ref, refType string) error { + if info, err := os.Stat(repoDir); err != nil { + return fmt.Errorf("git repo %s does not exist: %w", repoDir, err) + } else if !info.IsDir() { + return fmt.Errorf("git repo %s is not a directory", repoDir) + } + + if refType == "tag" { + if err := runGit(repoDir, "fetch", "--tags", "--progress"); err != nil { + return err + } + if ref != "" { + if err := runGit(repoDir, "checkout", ref); err != nil { + return err + } + } + return nil + } + + // For branches (or no ref), do a regular pull + return runGit(repoDir, "pull", "--progress") +} + +// ResetPull performs a force update by resetting the working tree to match the remote. +// For branches, it fetches and does a hard reset to the remote tracking branch. +// For tags, it fetches tags and checks out the specified tag, discarding local changes. +func ResetPull(repoDir, ref, refType string) error { + if info, err := os.Stat(repoDir); err != nil { + return fmt.Errorf("git repo %s does not exist: %w", repoDir, err) + } else if !info.IsDir() { + return fmt.Errorf("git repo %s is not a directory", repoDir) + } + + if refType == "tag" { + if err := runGit(repoDir, "fetch", "--tags", "--force", "--progress"); err != nil { + return err + } + // Discard local changes, then checkout tag + if err := runGit(repoDir, "checkout", "--force", ref); err != nil { + return err + } + return runGit(repoDir, "clean", "-fd") + } + + // For branches: fetch, then hard reset to remote + if err := runGit(repoDir, "fetch", "--progress"); err != nil { + return err + } + + // Determine the remote tracking ref + resetTarget := "FETCH_HEAD" + if ref != "" { + resetTarget = "origin/" + ref + } + if err := runGit(repoDir, "reset", "--hard", resetTarget); err != nil { + return err + } + return runGit(repoDir, "clean", "-fd") +} + +// runGit executes a git command, streams progress to stderr, and captures output for error messages. +func runGit(dir string, args ...string) error { + cmd := exec.Command("git", args...) + if dir != "" { + cmd.Dir = dir + } + + // Capture stderr for error diagnostics while also streaming it + var stderrBuf bytes.Buffer + cmd.Stdout = os.Stderr + cmd.Stderr = io.MultiWriter(os.Stderr, &stderrBuf) + + if err := cmd.Run(); err != nil { + stderr := strings.TrimSpace(stderrBuf.String()) + cmdStr := "git " + strings.Join(args, " ") + + if stderr != "" { + return errors.Wrapf(err, "%s:\n%s", cmdStr, stderr) + } + return errors.Wrap(err, cmdStr) + } + return nil +} + +// parseGitURL extracts the host and path from a git URL (HTTPS or SSH). +func parseGitURL(gitURL string) (host, repoPath string, err error) { + if sshURLPattern.MatchString(gitURL) { + // SSH format: git@github.com:org/repo.git + parts := strings.SplitN(gitURL, ":", 2) + if len(parts) != 2 { + return "", "", fmt.Errorf("invalid SSH git URL: %s", gitURL) + } + hostPart := parts[0] + if idx := strings.Index(hostPart, "@"); idx >= 0 { + hostPart = hostPart[idx+1:] + } + return hostPart, parts[1], nil + } + + u, err := url.Parse(gitURL) + if err != nil { + return "", "", fmt.Errorf("invalid git URL: %w", err) + } + return u.Host, u.Path, nil +} diff --git a/types/workspace/schema.yaml b/types/workspace/schema.yaml index 0f94a681..e151c90a 100644 --- a/types/workspace/schema.yaml +++ b/types/workspace/schema.yaml @@ -75,6 +75,25 @@ properties: A list of environment variable files to load for the workspace. These files should contain key-value pairs of environment variables. By default, the `.env` file in the workspace root is loaded if it exists. default: [] + gitRemote: + type: string + description: | + The git remote URL for the workspace. This is set automatically when a workspace is added from a git URL. + Used by `flow workspace update` to pull the latest changes. + default: "" + gitRef: + type: string + description: | + The git ref (branch or tag name) that was specified when the workspace was added from a git URL. + Used by `flow workspace update` to checkout the correct ref after pulling. + default: "" + gitRefType: + type: string + enum: ["", "branch", "tag"] + description: | + The type of git ref specified when the workspace was added. + Either "branch" or "tag". Empty if no ref was specified. + default: "" assignedName: type: string goJSONSchema: diff --git a/types/workspace/workspace.gen.go b/types/workspace/workspace.gen.go index 9fd80a4a..c6f8a180 100644 --- a/types/workspace/workspace.gen.go +++ b/types/workspace/workspace.gen.go @@ -55,6 +55,23 @@ type Workspace struct { // Executables corresponds to the JSON schema field "executables". Executables *ExecutableFilter `json:"executables,omitempty" yaml:"executables,omitempty" mapstructure:"executables,omitempty"` + // The git ref (branch or tag name) that was specified when the workspace was + // added from a git URL. + // Used by `flow workspace update` to checkout the correct ref after pulling. + // + GitRef string `json:"gitRef,omitempty" yaml:"gitRef,omitempty" mapstructure:"gitRef,omitempty"` + + // The type of git ref specified when the workspace was added. + // Either "branch" or "tag". Empty if no ref was specified. + // + GitRefType WorkspaceGitRefType `json:"gitRefType,omitempty" yaml:"gitRefType,omitempty" mapstructure:"gitRefType,omitempty"` + + // The git remote URL for the workspace. This is set automatically when a + // workspace is added from a git URL. + // Used by `flow workspace update` to pull the latest changes. + // + GitRemote string `json:"gitRemote,omitempty" yaml:"gitRemote,omitempty" mapstructure:"gitRemote,omitempty"` + // location corresponds to the JSON schema field "location". location string `json:"location,omitempty" yaml:"location,omitempty" mapstructure:"location,omitempty"` @@ -65,6 +82,12 @@ type Workspace struct { VerbAliases *WorkspaceVerbAliases `json:"verbAliases,omitempty" yaml:"verbAliases,omitempty" mapstructure:"verbAliases,omitempty"` } +type WorkspaceGitRefType string + +const WorkspaceGitRefTypeBlank WorkspaceGitRefType = "" +const WorkspaceGitRefTypeBranch WorkspaceGitRefType = "branch" +const WorkspaceGitRefTypeTag WorkspaceGitRefType = "tag" + type WorkspaceTags common.Tags type WorkspaceVerbAliases map[string][]string From 506f9e9442d87a71d08d7ebccda483d7a82b3a99 Mon Sep 17 00:00:00 2001 From: Jahvon Dockery Date: Mon, 13 Apr 2026 21:09:36 -0400 Subject: [PATCH 2/6] fix: resolve lint issues in workspace command Extract cloneGitWorkspace helper to reduce nesting complexity, fix wasted assignment, and wrap long log line. Co-Authored-By: Claude Opus 4.6 (1M context) --- cmd/internal/workspace.go | 74 ++++++++++++++++++++++----------------- 1 file changed, 41 insertions(+), 33 deletions(-) diff --git a/cmd/internal/workspace.go b/cmd/internal/workspace.go index 80e24f96..038e9f9e 100644 --- a/cmd/internal/workspace.go +++ b/cmd/internal/workspace.go @@ -78,38 +78,7 @@ func addWorkspaceFunc(ctx *context.Context, cmd *cobra.Command, args []string) { var path string if git.IsGitURL(pathOrURL) { - clonePath, err := git.ClonePath(pathOrURL) - if err != nil { - logger.Log().FatalErr(errors.Wrap(err, "unable to determine clone path")) - } - - logger.Log().Infof("Cloning %s...", pathOrURL) - if err := git.Clone(pathOrURL, clonePath, branch, tag); err != nil { - logger.Log().FatalErr(errors.Wrap(err, "unable to clone git repository")) - } - path = clonePath - - // Write git metadata to workspace config after clone - wsCfg := &workspace.Workspace{} - if filesystem.WorkspaceConfigExists(path) { - wsCfg, err = filesystem.LoadWorkspaceConfig(name, path) - if err != nil { - logger.Log().FatalErr(errors.Wrap(err, "unable to load cloned workspace config")) - } - } else { - wsCfg = workspace.DefaultWorkspaceConfig(name) - } - wsCfg.GitRemote = pathOrURL - if branch != "" { - wsCfg.GitRef = branch - wsCfg.GitRefType = workspace.WorkspaceGitRefTypeBranch - } else if tag != "" { - wsCfg.GitRef = tag - wsCfg.GitRefType = workspace.WorkspaceGitRefTypeTag - } - if err := filesystem.WriteWorkspaceConfig(path, wsCfg); err != nil { - logger.Log().FatalErr(errors.Wrap(err, "unable to write workspace config with git metadata")) - } + path = cloneGitWorkspace(name, pathOrURL, branch, tag) } else { if branch != "" || tag != "" { logger.Log().Fatalf("--branch and --tag flags are only supported with Git URLs") @@ -141,6 +110,42 @@ func addWorkspaceFunc(ctx *context.Context, cmd *cobra.Command, args []string) { logger.Log().PlainTextSuccess(fmt.Sprintf("Workspace '%s' created in %s", name, path)) } +func cloneGitWorkspace(name, gitURL, branch, tag string) string { + clonePath, err := git.ClonePath(gitURL) + if err != nil { + logger.Log().FatalErr(errors.Wrap(err, "unable to determine clone path")) + } + + logger.Log().Infof("Cloning %s...", gitURL) + if err := git.Clone(gitURL, clonePath, branch, tag); err != nil { + logger.Log().FatalErr(errors.Wrap(err, "unable to clone git repository")) + } + + var wsCfg *workspace.Workspace + if filesystem.WorkspaceConfigExists(clonePath) { + wsCfg, err = filesystem.LoadWorkspaceConfig(name, clonePath) + if err != nil { + logger.Log().FatalErr(errors.Wrap(err, "unable to load cloned workspace config")) + } + } else { + wsCfg = workspace.DefaultWorkspaceConfig(name) + } + + wsCfg.GitRemote = gitURL + if branch != "" { + wsCfg.GitRef = branch + wsCfg.GitRefType = workspace.WorkspaceGitRefTypeBranch + } else if tag != "" { + wsCfg.GitRef = tag + wsCfg.GitRefType = workspace.WorkspaceGitRefTypeTag + } + + if err := filesystem.WriteWorkspaceConfig(clonePath, wsCfg); err != nil { + logger.Log().FatalErr(errors.Wrap(err, "unable to write workspace config with git metadata")) + } + return clonePath +} + func resolveLocalPath(path, name string) string { switch { case path == "": @@ -221,7 +226,10 @@ func updateWorkspaceFunc(ctx *context.Context, cmd *cobra.Command, args []string } if force { - logger.Log().Warnf("Force updating workspace '%s' from %s (local changes will be discarded)...", workspaceName, wsCfg.GitRemote) + logger.Log().Warnf( + "Force updating workspace '%s' from %s (local changes will be discarded)...", + workspaceName, wsCfg.GitRemote, + ) } else { logger.Log().Infof("Updating workspace '%s' from %s...", workspaceName, wsCfg.GitRemote) } From 847d4a8168a33378a5d8ec2c3ade3ff51da69e09 Mon Sep 17 00:00:00 2001 From: Jahvon Dockery Date: Mon, 13 Apr 2026 21:14:01 -0400 Subject: [PATCH 3/6] test: add e2e tests for git workspace commands Tests clone from a local bare repo (file:// URL), workspace get, update, force update, and sync --git. Also adds file:// protocol support to IsGitURL for local testing. Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/services/git/git.go | 9 +- tests/git_workspace_e2e_test.go | 161 ++++++++++++++++++++++++++++++++ 2 files changed, 169 insertions(+), 1 deletion(-) create mode 100644 tests/git_workspace_e2e_test.go diff --git a/internal/services/git/git.go b/internal/services/git/git.go index 551c45bf..317c25ac 100644 --- a/internal/services/git/git.go +++ b/internal/services/git/git.go @@ -27,6 +27,9 @@ func IsGitURL(s string) bool { if err != nil { return false } + if u.Scheme == "file" && u.Path != "" { + return true + } return (u.Scheme == "https" || u.Scheme == "http" || u.Scheme == "ssh") && u.Host != "" && strings.HasSuffix(u.Path, ".git") @@ -176,5 +179,9 @@ func parseGitURL(gitURL string) (host, repoPath string, err error) { if err != nil { return "", "", fmt.Errorf("invalid git URL: %w", err) } - return u.Host, u.Path, nil + host = u.Host + if host == "" { + host = "localhost" + } + return host, u.Path, nil } diff --git a/tests/git_workspace_e2e_test.go b/tests/git_workspace_e2e_test.go new file mode 100644 index 00000000..2d5386bd --- /dev/null +++ b/tests/git_workspace_e2e_test.go @@ -0,0 +1,161 @@ +//go:build e2e + +package tests_test + +import ( + stdCtx "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/flowexec/flow/pkg/filesystem" + "github.com/flowexec/flow/tests/utils" +) + +var _ = Describe("git workspace e2e", Ordered, func() { + var ( + ctx *utils.Context + run *utils.CommandRunner + + bareRepoDir string // local bare repo path (for cleanup) + bareRepoURL string // file:// URL for the bare repo + wsName string + ) + + BeforeAll(func() { + ctx = utils.NewContext(stdCtx.Background(), GinkgoTB()) + run = utils.NewE2ECommandRunner() + wsName = "git-test-ws" + + // Create a local bare git repo with a flow.yaml as a test fixture. + bareRepoDir = initBareRepo(GinkgoTB()) + bareRepoURL = "file://" + bareRepoDir + }) + + BeforeEach(func() { + utils.ResetTestContext(ctx, GinkgoTB()) + }) + + AfterEach(func() { + ctx.Finalize() + }) + + AfterAll(func() { + Expect(os.RemoveAll(bareRepoDir)).To(Succeed()) + }) + + When("adding a workspace from a local git URL (flow workspace add)", func() { + It("clones and registers the workspace", func() { + stdOut := ctx.StdOut() + Expect(run.Run( + ctx.Context, "workspace", "add", wsName, bareRepoURL, + )).To(Succeed()) + out, err := readFileContent(stdOut) + Expect(err).NotTo(HaveOccurred()) + Expect(out).To(ContainSubstring(fmt.Sprintf("Workspace '%s' created", wsName))) + + // Verify the workspace was cloned to the cache directory + clonedPath := ctx.Config.Workspaces[wsName] + Expect(clonedPath).NotTo(BeEmpty()) + Expect(filesystem.WorkspaceConfigExists(clonedPath)).To(BeTrue()) + }) + }) + + When("getting the git workspace (flow workspace get)", func() { + It("should return the workspace with git metadata", func() { + stdOut := ctx.StdOut() + Expect(run.Run(ctx.Context, "workspace", "get", wsName)).To(Succeed()) + out, err := readFileContent(stdOut) + Expect(err).NotTo(HaveOccurred()) + Expect(out).To(ContainSubstring(wsName)) + }) + }) + + When("updating the git workspace (flow workspace update)", func() { + It("pulls latest changes successfully", func() { + stdOut := ctx.StdOut() + Expect(run.Run(ctx.Context, "workspace", "update", wsName)).To(Succeed()) + out, err := readFileContent(stdOut) + Expect(err).NotTo(HaveOccurred()) + Expect(out).To(ContainSubstring(fmt.Sprintf("Workspace '%s' updated", wsName))) + }) + + It("force updates successfully", func() { + stdOut := ctx.StdOut() + Expect(run.Run( + ctx.Context, "workspace", "update", wsName, "--force", + )).To(Succeed()) + out, err := readFileContent(stdOut) + Expect(err).NotTo(HaveOccurred()) + Expect(out).To(ContainSubstring(fmt.Sprintf("Workspace '%s' updated", wsName))) + }) + }) + + When("syncing with --git flag (flow sync --git)", func() { + It("pulls all git workspaces and syncs cache", func() { + stdOut := ctx.StdOut() + Expect(run.Run(ctx.Context, "sync", "--git")).To(Succeed()) + out, err := readFileContent(stdOut) + Expect(err).NotTo(HaveOccurred()) + Expect(out).To(ContainSubstring("Synced flow cache")) + }) + }) + + // Note: Negative test cases (e.g. updating a non-git workspace, conflicting flags) + // use logger.Fatalf which calls tb.Fatalf in the test context. Since tb.Fatalf + // invokes runtime.Goexit, these cannot be caught as errors by the CommandRunner. +}) + +// initBareRepo creates a local bare git repo with a flow.yaml file, +// suitable for use as a test "remote" without any network calls. +func initBareRepo(tb testing.TB) string { + // Create a working directory to build the initial commit + workDir, err := os.MkdirTemp("", "flow-git-work-*") + Expect(err).NotTo(HaveOccurred()) + + // git init + gitCmd(tb, workDir, "init", "-b", "main") + gitCmd(tb, workDir, "config", "user.email", "test@test.com") + gitCmd(tb, workDir, "config", "user.name", "Test") + + // Create a flow.yaml + flowYAML := `displayName: Git Test Workspace +description: A test workspace from git +tags: + - test + - git +` + Expect(os.WriteFile( + filepath.Join(workDir, "flow.yaml"), []byte(flowYAML), 0600, + )).To(Succeed()) + + gitCmd(tb, workDir, "add", ".") + gitCmd(tb, workDir, "commit", "-m", "initial commit") + + // Create a bare clone to act as the "remote" + bareDir, err := os.MkdirTemp("", "flow-git-bare-*") + Expect(err).NotTo(HaveOccurred()) + // Remove the dir so git clone --bare can create it + Expect(os.RemoveAll(bareDir)).To(Succeed()) + + cmd := exec.Command("git", "clone", "--bare", workDir, bareDir) + cmd.Stderr = os.Stderr + Expect(cmd.Run()).To(Succeed()) + + // Clean up the working directory + Expect(os.RemoveAll(workDir)).To(Succeed()) + + return bareDir +} + +func gitCmd(tb testing.TB, dir string, args ...string) { + cmd := exec.Command("git", args...) + cmd.Dir = dir + cmd.Stderr = os.Stderr + Expect(cmd.Run()).To(Succeed(), "git %v failed", args) +} From 150c7d532b4d79545ba1e2f37ffd128773b74dff Mon Sep 17 00:00:00 2001 From: Jahvon Dockery Date: Mon, 13 Apr 2026 21:14:49 -0400 Subject: [PATCH 4/6] docs: document file:// protocol support for git workspaces Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/guides/workspaces.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/guides/workspaces.md b/docs/guides/workspaces.md index b14c5562..04d0324e 100644 --- a/docs/guides/workspaces.md +++ b/docs/guides/workspaces.md @@ -41,8 +41,13 @@ flow workspace add dev-tools https://github.com/myorg/tools.git --branch develop # Clone a specific tag flow workspace add stable https://github.com/myorg/tools.git --tag v1.0.0 + +# Clone from a local bare repo (useful for testing or air-gapped environments) +flow workspace add local-tools file:///path/to/bare/repo ``` +Flow supports HTTPS, SSH, and `file://` Git URLs. The `file://` protocol is useful for local testing, air-gapped environments, or pointing at bare repos on a shared filesystem. + Git workspaces are stored in `~/.cache/flow/git-workspaces/` following Go module conventions (e.g., `github.com/myorg/tools/`). The git remote URL and branch/tag information are saved in the workspace's `flow.yaml` so they can be used for updates. ### Updating Git Workspaces From 8982f334ef6b03bbb9715e8082693a9f4b9a7fd7 Mon Sep 17 00:00:00 2001 From: Jahvon Dockery Date: Mon, 13 Apr 2026 21:17:06 -0400 Subject: [PATCH 5/6] fix: reduce nesting complexity in addWorkspaceFunc Extract initLocalWorkspace helper to flatten the if/else block. Co-Authored-By: Claude Opus 4.6 (1M context) --- cmd/internal/workspace.go | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/cmd/internal/workspace.go b/cmd/internal/workspace.go index 038e9f9e..829c6fe4 100644 --- a/cmd/internal/workspace.go +++ b/cmd/internal/workspace.go @@ -80,15 +80,7 @@ func addWorkspaceFunc(ctx *context.Context, cmd *cobra.Command, args []string) { if git.IsGitURL(pathOrURL) { path = cloneGitWorkspace(name, pathOrURL, branch, tag) } else { - if branch != "" || tag != "" { - logger.Log().Fatalf("--branch and --tag flags are only supported with Git URLs") - } - path = resolveLocalPath(pathOrURL, name) - if !filesystem.WorkspaceConfigExists(path) { - if err := filesystem.InitWorkspaceConfig(name, path); err != nil { - logger.Log().FatalErr(err) - } - } + path = initLocalWorkspace(name, pathOrURL, branch, tag) } userConfig.Workspaces[name] = path @@ -110,6 +102,19 @@ func addWorkspaceFunc(ctx *context.Context, cmd *cobra.Command, args []string) { logger.Log().PlainTextSuccess(fmt.Sprintf("Workspace '%s' created in %s", name, path)) } +func initLocalWorkspace(name, pathOrURL, branch, tag string) string { + if branch != "" || tag != "" { + logger.Log().Fatalf("--branch and --tag flags are only supported with Git URLs") + } + path := resolveLocalPath(pathOrURL, name) + if !filesystem.WorkspaceConfigExists(path) { + if err := filesystem.InitWorkspaceConfig(name, path); err != nil { + logger.Log().FatalErr(err) + } + } + return path +} + func cloneGitWorkspace(name, gitURL, branch, tag string) string { clonePath, err := git.ClonePath(gitURL) if err != nil { From 57e13e26304acfac3986fc5f531e90c8f49b5281 Mon Sep 17 00:00:00 2001 From: Jahvon Dockery Date: Mon, 13 Apr 2026 21:21:30 -0400 Subject: [PATCH 6/6] docs: note git as optional dependency for git workspace features Add prerequisite callout in installation docs and workspace guide. Add EnsureInstalled runtime check so users get a clear error if git is not on PATH. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/guides/workspaces.md | 2 ++ docs/installation.md | 2 ++ internal/services/git/git.go | 12 ++++++++++++ 3 files changed, 16 insertions(+) diff --git a/docs/guides/workspaces.md b/docs/guides/workspaces.md index 04d0324e..3d7f1b12 100644 --- a/docs/guides/workspaces.md +++ b/docs/guides/workspaces.md @@ -27,6 +27,8 @@ When you add a workspace, flow creates a `flow.yaml` configuration file in the r #### Git Workspaces +> **Prerequisite:** Git must be installed and available on your `PATH` to use git workspace features. + You can also add workspaces directly from Git repositories. Flow clones the repository to its cache directory and registers it as a workspace: ```shell diff --git a/docs/installation.md b/docs/installation.md index 16eec181..71353321 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -5,6 +5,8 @@ title: Installation # Installation > **System Requirements:** flow supports Linux and macOS systems. On Linux, you'll need `xclip` installed to use clipboard features. +> +> **Optional:** [Git](https://git-scm.com/) is required for [git workspace](guides/workspaces.md#git-workspaces) features (`workspace add` from URLs, `workspace update`, `sync --git`). ## Quick Install diff --git a/internal/services/git/git.go b/internal/services/git/git.go index 317c25ac..d42aeb1e 100644 --- a/internal/services/git/git.go +++ b/internal/services/git/git.go @@ -136,8 +136,20 @@ func ResetPull(repoDir, ref, refType string) error { return runGit(repoDir, "clean", "-fd") } +// EnsureInstalled checks that the git binary is available on PATH. +func EnsureInstalled() error { + if _, err := exec.LookPath("git"); err != nil { + return fmt.Errorf("git is not installed or not in PATH (required for git workspace features)") + } + return nil +} + // runGit executes a git command, streams progress to stderr, and captures output for error messages. func runGit(dir string, args ...string) error { + if err := EnsureInstalled(); err != nil { + return err + } + cmd := exec.Command("git", args...) if dir != "" { cmd.Dir = dir