Skip to content

Commit 93afc60

Browse files
jahvonclaude
andauthored
feat: Add git workspace support with clone, update, and force pull (#388)
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) <noreply@anthropic.com>
1 parent c059a01 commit 93afc60

File tree

15 files changed

+805
-54
lines changed

15 files changed

+805
-54
lines changed

cmd/internal/flags/types.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,34 @@ var SetAfterCreateFlag = &Metadata{
111111
Required: false,
112112
}
113113

114+
var GitBranchFlag = &Metadata{
115+
Name: "branch",
116+
Shorthand: "b",
117+
Usage: "Git branch to checkout when cloning a git workspace",
118+
Default: "",
119+
Required: false,
120+
}
121+
122+
var GitTagFlag = &Metadata{
123+
Name: "tag",
124+
Usage: "Git tag to checkout when cloning a git workspace",
125+
Default: "",
126+
}
127+
128+
var GitPullFlag = &Metadata{
129+
Name: "git",
130+
Shorthand: "g",
131+
Usage: "Pull latest changes for all git-sourced workspaces before syncing",
132+
Default: false,
133+
Required: false,
134+
}
135+
136+
var ForceFlag = &Metadata{
137+
Name: "force",
138+
Usage: "Force update by discarding local changes (hard reset to remote)",
139+
Default: false,
140+
}
141+
114142
var FixedWsModeFlag = &Metadata{
115143
Name: "fixed",
116144
Shorthand: "f",

cmd/internal/sync.go

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,31 +6,86 @@ import (
66

77
"github.com/spf13/cobra"
88

9+
"github.com/flowexec/flow/cmd/internal/flags"
10+
"github.com/flowexec/flow/internal/services/git"
911
"github.com/flowexec/flow/pkg/cache"
1012
"github.com/flowexec/flow/pkg/context"
13+
"github.com/flowexec/flow/pkg/filesystem"
1114
"github.com/flowexec/flow/pkg/logger"
1215
)
1316

1417
func RegisterSyncCmd(ctx *context.Context, rootCmd *cobra.Command) {
1518
subCmd := &cobra.Command{
1619
Use: "sync",
1720
Short: "Refresh workspace cache and discover new executables.",
18-
Args: cobra.NoArgs,
21+
Long: "Refresh the workspace cache and discover new executables. " +
22+
"Use --git to also pull latest changes for all git-sourced workspaces before syncing. " +
23+
"Use --force with --git to discard local changes and hard reset to the remote.",
24+
Args: cobra.NoArgs,
1925
PreRun: func(cmd *cobra.Command, args []string) {
2026
printContext(ctx, cmd)
2127
},
2228
Run: func(cmd *cobra.Command, args []string) {
2329
syncFunc(ctx, cmd, args)
2430
},
2531
}
32+
RegisterFlag(ctx, subCmd, *flags.GitPullFlag)
33+
RegisterFlag(ctx, subCmd, *flags.ForceFlag)
2634
rootCmd.AddCommand(subCmd)
2735
}
2836

29-
func syncFunc(ctx *context.Context, _ *cobra.Command, _ []string) {
37+
func syncFunc(ctx *context.Context, cmd *cobra.Command, _ []string) {
38+
pullGit := flags.ValueFor[bool](cmd, *flags.GitPullFlag, false)
39+
force := flags.ValueFor[bool](cmd, *flags.ForceFlag, false)
40+
41+
if force && !pullGit {
42+
logger.Log().Fatalf("--force can only be used with --git")
43+
}
44+
3045
start := time.Now()
46+
47+
if pullGit {
48+
pullGitWorkspaces(ctx, force)
49+
}
50+
3151
if err := cache.UpdateAll(ctx.DataStore); err != nil {
3252
logger.Log().FatalErr(err)
3353
}
3454
duration := time.Since(start)
3555
logger.Log().PlainTextSuccess(fmt.Sprintf("Synced flow cache (%s)", duration.Round(time.Second)))
3656
}
57+
58+
func pullGitWorkspaces(ctx *context.Context, force bool) {
59+
cfg := ctx.Config
60+
for name, path := range cfg.Workspaces {
61+
wsCfg, err := filesystem.LoadWorkspaceConfig(name, path)
62+
if err != nil {
63+
logger.Log().Warnf("Skipping workspace '%s': %v", name, err)
64+
continue
65+
}
66+
if wsCfg.GitRemote == "" {
67+
continue
68+
}
69+
70+
logger.Log().Infof("Pulling workspace '%s' from %s...", name, wsCfg.GitRemote)
71+
pullStart := time.Now()
72+
73+
var pullErr error
74+
if force {
75+
pullErr = git.ResetPull(path, wsCfg.GitRef, string(wsCfg.GitRefType))
76+
} else {
77+
pullErr = git.Pull(path, wsCfg.GitRef, string(wsCfg.GitRefType))
78+
}
79+
80+
if pullErr != nil {
81+
logger.Log().Errorf("Failed to pull workspace '%s': %v", name, pullErr)
82+
if !force {
83+
logger.Log().Warnf("Hint: use --force to discard local changes and hard reset to remote")
84+
}
85+
continue
86+
}
87+
88+
pullDuration := time.Since(pullStart)
89+
logger.Log().Infof("Workspace '%s' updated (%s)", name, pullDuration.Round(time.Millisecond))
90+
}
91+
}

cmd/internal/workspace.go

Lines changed: 169 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414

1515
"github.com/flowexec/flow/cmd/internal/flags"
1616
workspaceIO "github.com/flowexec/flow/internal/io/workspace"
17+
"github.com/flowexec/flow/internal/services/git"
1718
"github.com/flowexec/flow/pkg/cache"
1819
"github.com/flowexec/flow/pkg/context"
1920
"github.com/flowexec/flow/pkg/filesystem"
@@ -30,6 +31,7 @@ func RegisterWorkspaceCmd(ctx *context.Context, rootCmd *cobra.Command) {
3031
Short: "Manage development workspaces.",
3132
}
3233
registerAddWorkspaceCmd(ctx, wsCmd)
34+
registerUpdateWorkspaceCmd(ctx, wsCmd)
3335
registerSwitchWorkspaceCmd(ctx, wsCmd)
3436
registerRemoveWorkspaceCmd(ctx, wsCmd)
3537
registerListWorkspaceCmd(ctx, wsCmd)
@@ -39,78 +41,221 @@ func RegisterWorkspaceCmd(ctx *context.Context, rootCmd *cobra.Command) {
3941

4042
func registerAddWorkspaceCmd(ctx *context.Context, wsCmd *cobra.Command) {
4143
createCmd := &cobra.Command{
42-
Use: "add NAME PATH",
44+
Use: "add NAME PATH_OR_GIT_URL",
4345
Aliases: []string{"init", "create", "new"},
44-
Short: "Initialize a new workspace.",
45-
Args: cobra.ExactArgs(2),
46-
Run: func(cmd *cobra.Command, args []string) { addWorkspaceFunc(ctx, cmd, args) },
46+
Short: "Initialize a new workspace from a local path or Git URL.",
47+
Long: "Initialize a new workspace. PATH_OR_GIT_URL can be a local directory path " +
48+
"or a Git repository URL (HTTPS or SSH). When a Git URL is provided, " +
49+
"the repository is cloned to the flow cache directory and registered as a workspace.\n\n" +
50+
"Examples:\n" +
51+
" flow workspace add my-ws ./path/to/dir\n" +
52+
" flow workspace add shared https://github.com/org/flows.git\n" +
53+
" flow workspace add tools git@github.com:org/tools.git --branch main\n" +
54+
" flow workspace add stable https://github.com/org/flows.git --tag v1.0.0",
55+
Args: cobra.ExactArgs(2),
56+
Run: func(cmd *cobra.Command, args []string) { addWorkspaceFunc(ctx, cmd, args) },
4757
}
4858
RegisterFlag(ctx, createCmd, *flags.SetAfterCreateFlag)
59+
RegisterFlag(ctx, createCmd, *flags.GitBranchFlag)
60+
RegisterFlag(ctx, createCmd, *flags.GitTagFlag)
4961
wsCmd.AddCommand(createCmd)
5062
}
5163

5264
func addWorkspaceFunc(ctx *context.Context, cmd *cobra.Command, args []string) {
5365
name := args[0]
54-
path := args[1]
66+
pathOrURL := args[1]
5567

5668
userConfig := ctx.Config
5769
if _, found := userConfig.Workspaces[name]; found {
5870
logger.Log().Fatalf("workspace %s already exists at %s", name, userConfig.Workspaces[name])
5971
}
6072

73+
branch := flags.ValueFor[string](cmd, *flags.GitBranchFlag, false)
74+
tag := flags.ValueFor[string](cmd, *flags.GitTagFlag, false)
75+
if branch != "" && tag != "" {
76+
logger.Log().Fatalf("cannot specify both --branch and --tag")
77+
}
78+
79+
var path string
80+
if git.IsGitURL(pathOrURL) {
81+
path = cloneGitWorkspace(name, pathOrURL, branch, tag)
82+
} else {
83+
path = initLocalWorkspace(name, pathOrURL, branch, tag)
84+
}
85+
86+
userConfig.Workspaces[name] = path
87+
88+
set := flags.ValueFor[bool](cmd, *flags.SetAfterCreateFlag, false)
89+
if set {
90+
userConfig.CurrentWorkspace = name
91+
logger.Log().Infof("Workspace '%s' set as current workspace", name)
92+
}
93+
94+
if err := filesystem.WriteConfig(userConfig); err != nil {
95+
logger.Log().FatalErr(err)
96+
}
97+
98+
if err := cache.UpdateAll(ctx.DataStore); err != nil {
99+
logger.Log().FatalErr(errors.Wrap(err, "failure updating cache"))
100+
}
101+
102+
logger.Log().PlainTextSuccess(fmt.Sprintf("Workspace '%s' created in %s", name, path))
103+
}
104+
105+
func initLocalWorkspace(name, pathOrURL, branch, tag string) string {
106+
if branch != "" || tag != "" {
107+
logger.Log().Fatalf("--branch and --tag flags are only supported with Git URLs")
108+
}
109+
path := resolveLocalPath(pathOrURL, name)
110+
if !filesystem.WorkspaceConfigExists(path) {
111+
if err := filesystem.InitWorkspaceConfig(name, path); err != nil {
112+
logger.Log().FatalErr(err)
113+
}
114+
}
115+
return path
116+
}
117+
118+
func cloneGitWorkspace(name, gitURL, branch, tag string) string {
119+
clonePath, err := git.ClonePath(gitURL)
120+
if err != nil {
121+
logger.Log().FatalErr(errors.Wrap(err, "unable to determine clone path"))
122+
}
123+
124+
logger.Log().Infof("Cloning %s...", gitURL)
125+
if err := git.Clone(gitURL, clonePath, branch, tag); err != nil {
126+
logger.Log().FatalErr(errors.Wrap(err, "unable to clone git repository"))
127+
}
128+
129+
var wsCfg *workspace.Workspace
130+
if filesystem.WorkspaceConfigExists(clonePath) {
131+
wsCfg, err = filesystem.LoadWorkspaceConfig(name, clonePath)
132+
if err != nil {
133+
logger.Log().FatalErr(errors.Wrap(err, "unable to load cloned workspace config"))
134+
}
135+
} else {
136+
wsCfg = workspace.DefaultWorkspaceConfig(name)
137+
}
138+
139+
wsCfg.GitRemote = gitURL
140+
if branch != "" {
141+
wsCfg.GitRef = branch
142+
wsCfg.GitRefType = workspace.WorkspaceGitRefTypeBranch
143+
} else if tag != "" {
144+
wsCfg.GitRef = tag
145+
wsCfg.GitRefType = workspace.WorkspaceGitRefTypeTag
146+
}
147+
148+
if err := filesystem.WriteWorkspaceConfig(clonePath, wsCfg); err != nil {
149+
logger.Log().FatalErr(errors.Wrap(err, "unable to write workspace config with git metadata"))
150+
}
151+
return clonePath
152+
}
153+
154+
func resolveLocalPath(path, name string) string {
61155
switch {
62156
case path == "":
63-
path = filepath.Join(filesystem.CachedDataDirPath(), name)
157+
return filepath.Join(filesystem.CachedDataDirPath(), name)
64158
case path == "." || strings.HasPrefix(path, "./"):
65159
wd, err := os.Getwd()
66160
if err != nil {
67161
logger.Log().FatalErr(err)
68162
}
69163
if path == "." {
70-
path = wd
71-
} else {
72-
path = fmt.Sprintf("%s/%s", wd, path[2:])
164+
return wd
73165
}
166+
return fmt.Sprintf("%s/%s", wd, path[2:])
74167
case path == "~" || strings.HasPrefix(path, "~/"):
75168
hd, err := os.UserHomeDir()
76169
if err != nil {
77170
logger.Log().FatalErr(err)
78171
}
79172
if path == "~" {
80-
path = hd
81-
} else {
82-
path = fmt.Sprintf("%s/%s", hd, path[2:])
173+
return hd
83174
}
175+
return fmt.Sprintf("%s/%s", hd, path[2:])
84176
case !filepath.IsAbs(path):
85177
wd, err := os.Getwd()
86178
if err != nil {
87179
logger.Log().FatalErr(err)
88180
}
89-
path = fmt.Sprintf("%s/%s", wd, path)
181+
return fmt.Sprintf("%s/%s", wd, path)
182+
default:
183+
return path
90184
}
185+
}
91186

92-
if !filesystem.WorkspaceConfigExists(path) {
93-
if err := filesystem.InitWorkspaceConfig(name, path); err != nil {
94-
logger.Log().FatalErr(err)
187+
func registerUpdateWorkspaceCmd(ctx *context.Context, wsCmd *cobra.Command) {
188+
updateCmd := &cobra.Command{
189+
Use: "update [NAME]",
190+
Aliases: []string{"pull", "sync"},
191+
Short: "Pull latest changes for a git-sourced workspace.",
192+
Long: "Pull the latest changes from the git remote for a workspace that was added from a Git URL. " +
193+
"If NAME is omitted, the current workspace is used.\n\n" +
194+
"This respects the branch or tag that was originally specified when the workspace was added.\n" +
195+
"Use --force to discard local changes and hard reset to the remote.",
196+
Args: cobra.MaximumNArgs(1),
197+
ValidArgsFunction: func(_ *cobra.Command, _ []string, _ string) ([]cobra.Completion, cobra.ShellCompDirective) {
198+
return maps.Keys(ctx.Config.Workspaces), cobra.ShellCompDirectiveNoFileComp
199+
},
200+
Run: func(cmd *cobra.Command, args []string) { updateWorkspaceFunc(ctx, cmd, args) },
201+
}
202+
RegisterFlag(ctx, updateCmd, *flags.ForceFlag)
203+
wsCmd.AddCommand(updateCmd)
204+
}
205+
206+
func updateWorkspaceFunc(ctx *context.Context, cmd *cobra.Command, args []string) {
207+
var workspaceName, wsPath string
208+
if len(args) == 1 {
209+
workspaceName = args[0]
210+
wsPath = ctx.Config.Workspaces[workspaceName]
211+
if wsPath == "" {
212+
logger.Log().Fatalf("workspace %s not found", workspaceName)
95213
}
214+
} else {
215+
if ctx.CurrentWorkspace == nil {
216+
logger.Log().Fatalf("no current workspace set")
217+
}
218+
workspaceName = ctx.CurrentWorkspace.AssignedName()
219+
wsPath = ctx.CurrentWorkspace.Location()
96220
}
97-
userConfig.Workspaces[name] = path
98221

99-
set := flags.ValueFor[bool](cmd, *flags.SetAfterCreateFlag, false)
100-
if set {
101-
userConfig.CurrentWorkspace = name
102-
logger.Log().Infof("Workspace '%s' set as current workspace", name)
222+
force := flags.ValueFor[bool](cmd, *flags.ForceFlag, false)
223+
224+
wsCfg, err := filesystem.LoadWorkspaceConfig(workspaceName, wsPath)
225+
if err != nil {
226+
logger.Log().FatalErr(errors.Wrap(err, "unable to load workspace config"))
103227
}
104228

105-
if err := filesystem.WriteConfig(userConfig); err != nil {
106-
logger.Log().FatalErr(err)
229+
if wsCfg.GitRemote == "" {
230+
logger.Log().Fatalf("workspace '%s' is not a git-sourced workspace (no gitRemote set in flow.yaml)", workspaceName)
231+
}
232+
233+
if force {
234+
logger.Log().Warnf(
235+
"Force updating workspace '%s' from %s (local changes will be discarded)...",
236+
workspaceName, wsCfg.GitRemote,
237+
)
238+
} else {
239+
logger.Log().Infof("Updating workspace '%s' from %s...", workspaceName, wsCfg.GitRemote)
240+
}
241+
242+
if force {
243+
err = git.ResetPull(wsPath, wsCfg.GitRef, string(wsCfg.GitRefType))
244+
} else {
245+
err = git.Pull(wsPath, wsCfg.GitRef, string(wsCfg.GitRefType))
246+
}
247+
if err != nil {
248+
if !force {
249+
logger.Log().Warnf("Hint: use --force to discard local changes and hard reset to remote")
250+
}
251+
logger.Log().FatalErr(errors.Wrap(err, "unable to update workspace"))
107252
}
108253

109254
if err := cache.UpdateAll(ctx.DataStore); err != nil {
110255
logger.Log().FatalErr(errors.Wrap(err, "failure updating cache"))
111256
}
112257

113-
logger.Log().PlainTextSuccess(fmt.Sprintf("Workspace '%s' created in %s", name, path))
258+
logger.Log().PlainTextSuccess(fmt.Sprintf("Workspace '%s' updated", workspaceName))
114259
}
115260

116261
func registerSwitchWorkspaceCmd(ctx *context.Context, setCmd *cobra.Command) {

0 commit comments

Comments
 (0)