Merge pull request #610 from g1f9/feat/workspace-init-local-dir

feat: support local directory paths in /workspace init
This commit is contained in:
cg33 authored and GitHub committed 2026-04-16 07:45:36 +08:00
commit 9178b77fef
4 files changed
+195 -17

No files matched your search

+58 -7
View File
@@ -3456,13 +3456,29 @@ func (e *Engine) handleWorkspaceCommand(p Platform, msg *Message, args []string)
e.reply(p, msg.ReplyCtx, e.i18n.Tf(successKey, wsName))
return true
}
initWorkspace := func(bindingKey, repoURL string, successKey MsgKey) bool {
if !looksLikeGitURL(repoURL) {
e.reply(p, msg.ReplyCtx, "That doesn't look like a git URL.")
initWorkspace := func(bindingKey, target string, successKey MsgKey) bool {
// Support local directory paths (absolute or relative to baseDir).
if looksLikeLocalDir(target) {
dirPath := target
if !filepath.IsAbs(dirPath) {
dirPath = filepath.Join(e.baseDir, dirPath)
}
info, err := os.Stat(dirPath)
if err != nil || !info.IsDir() {
e.reply(p, msg.ReplyCtx, e.i18n.Tf(MsgWsInitDirNotFound, target))
return false
}
e.workspaceBindings.Bind(bindingKey, channelKey, resolveChannelName(), normalizeWorkspacePath(dirPath))
e.reply(p, msg.ReplyCtx, e.i18n.Tf(successKey, dirPath))
return true
}
if !looksLikeGitURL(target) {
e.reply(p, msg.ReplyCtx, e.i18n.T(MsgWsInitInvalidTarget))
return false
}
repoName := extractRepoName(repoURL)
repoName := extractRepoName(target)
cloneTo := filepath.Join(e.baseDir, repoName)
if _, err := os.Stat(cloneTo); err == nil {
@@ -3471,9 +3487,9 @@ func (e *Engine) handleWorkspaceCommand(p Platform, msg *Message, args []string)
return true
}
e.reply(p, msg.ReplyCtx, e.i18n.Tf(MsgWsCloneProgress, repoURL))
e.reply(p, msg.ReplyCtx, e.i18n.Tf(MsgWsCloneProgress, target))
if err := gitClone(repoURL, cloneTo); err != nil {
if err := gitClone(target, cloneTo); err != nil {
e.reply(p, msg.ReplyCtx, e.i18n.Tf(MsgWsCloneFailed, err))
return false
}
@@ -10338,8 +10354,29 @@ func (e *Engine) handleWorkspaceInitFlow(p Platform, msg *Message, channelName s
switch flow.state {
case "awaiting_url":
// Accept local directory paths: bind directly without cloning.
if looksLikeLocalDir(content) {
dirPath := content
if !filepath.IsAbs(dirPath) {
dirPath = filepath.Join(e.baseDir, dirPath)
}
info, err := os.Stat(dirPath)
if err != nil || !info.IsDir() {
e.reply(p, msg.ReplyCtx, e.i18n.Tf(MsgWsInitDirNotFound, content))
return true
}
projectKey := "project:" + e.name
e.workspaceBindings.Bind(projectKey, channelKey, flow.channelName, normalizeWorkspacePath(dirPath))
e.initFlowsMu.Lock()
delete(e.initFlows, channelKey)
e.initFlowsMu.Unlock()
e.reply(p, msg.ReplyCtx, fmt.Sprintf(
"Bound workspace `%s` to this channel. Ready.", dirPath))
return true
}
if !looksLikeGitURL(content) {
e.reply(p, msg.ReplyCtx, "That doesn't look like a git URL. Please provide a URL like `https://github.com/org/repo` or `git@github.com:org/repo.git`.")
e.reply(p, msg.ReplyCtx, e.i18n.T(MsgWsInitInvalidTarget))
return true
}
repoName := extractRepoName(content)
@@ -10397,6 +10434,20 @@ func looksLikeGitURL(s string) bool {
strings.HasPrefix(s, "ssh://")
}
// looksLikeLocalDir returns true if the string looks like a local directory
// path (absolute path, home-relative, dot-relative, or a bare name that
// doesn't look like a URL).
func looksLikeLocalDir(s string) bool {
if s == "" {
return false
}
return strings.HasPrefix(s, "/") ||
strings.HasPrefix(s, "~/") ||
strings.HasPrefix(s, "./") ||
strings.HasPrefix(s, "../") ||
(!strings.Contains(s, "://") && !strings.Contains(s, "@"))
}
func extractRepoName(url string) string {
url = strings.TrimSuffix(url, ".git")
// Handle git@host:org/repo format
+82
View File
@@ -8066,6 +8066,88 @@ func TestWorkspace_SharedInit_BindsExistingDir(t *testing.T) {
}
}
func TestWorkspace_Init_LocalDirAbsolute(t *testing.T) {
p := &stubPlatformEngine{n: "test"}
e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish)
baseDir := t.TempDir()
wsDir := filepath.Join(baseDir, "my-project")
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
bindStore := filepath.Join(t.TempDir(), "bindings.json")
e.SetMultiWorkspace(baseDir, bindStore)
msg := &Message{
SessionKey: "test:ch1:user1",
Content: "/workspace init " + wsDir,
ReplyCtx: "ctx",
UserID: "user1",
}
e.handleCommand(p, msg, msg.Content)
normalizedWsDir := normalizeWorkspacePath(wsDir)
projectKey := "project:test"
if got := e.workspaceBindings.Lookup(projectKey, workspaceChannelKey("test", "ch1")); got == nil || got.Workspace != normalizedWsDir {
t.Fatalf("expected init binding %q, got %+v", normalizedWsDir, got)
}
}
func TestWorkspace_Init_LocalDirRelative(t *testing.T) {
p := &stubPlatformEngine{n: "test"}
e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish)
baseDir := t.TempDir()
wsDir := filepath.Join(baseDir, "my-project")
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
bindStore := filepath.Join(t.TempDir(), "bindings.json")
e.SetMultiWorkspace(baseDir, bindStore)
// Use relative name — should resolve under baseDir.
msg := &Message{
SessionKey: "test:ch1:user1",
Content: "/workspace init my-project",
ReplyCtx: "ctx",
UserID: "user1",
}
e.handleCommand(p, msg, msg.Content)
normalizedWsDir := normalizeWorkspacePath(wsDir)
projectKey := "project:test"
if got := e.workspaceBindings.Lookup(projectKey, workspaceChannelKey("test", "ch1")); got == nil || got.Workspace != normalizedWsDir {
t.Fatalf("expected init binding %q, got %+v", normalizedWsDir, got)
}
}
func TestWorkspace_Init_LocalDirNotFound(t *testing.T) {
p := &stubPlatformEngine{n: "test"}
e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish)
baseDir := t.TempDir()
bindStore := filepath.Join(t.TempDir(), "bindings.json")
e.SetMultiWorkspace(baseDir, bindStore)
msg := &Message{
SessionKey: "test:ch1:user1",
Content: "/workspace init nonexistent-dir",
ReplyCtx: "ctx",
UserID: "user1",
}
e.handleCommand(p, msg, msg.Content)
sent := p.getSent()
if len(sent) == 0 || !strings.Contains(sent[0], "nonexistent-dir") {
t.Fatalf("expected error mentioning missing dir, got %v", sent)
}
projectKey := "project:test"
if got := e.workspaceBindings.Lookup(projectKey, workspaceChannelKey("test", "ch1")); got != nil {
t.Fatalf("expected no binding for nonexistent dir, got %+v", got)
}
}
func TestWorkspace_Unbind_SharedBindingShowsHint(t *testing.T) {
p := &stubPlatformEngine{n: "test"}
e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish)
+26 -10
View File
@@ -549,6 +549,8 @@ const (
MsgWsCloneProgress MsgKey = "ws_clone_progress"
MsgWsCloneSuccess MsgKey = "ws_clone_success"
MsgWsCloneFailed MsgKey = "ws_clone_failed"
MsgWsInitDirNotFound MsgKey = "ws_init_dir_not_found"
MsgWsInitInvalidTarget MsgKey = "ws_init_invalid_target"
)
var messages = map[MsgKey]map[Language]string{
@@ -3518,11 +3520,11 @@ var messages = map[MsgKey]map[Language]string{
LangSpanish: "Uso: `/workspace [bind <nombre> | route <ruta-absoluta> | init <url> | unbind | list | shared ...]`",
},
MsgWsInitUsage: {
LangEnglish: "Usage: `/workspace init <git-url>`",
LangChinese: "用法: `/workspace init <git仓库地址>`",
LangTraditionalChinese: "用法: `/workspace init <git倉庫地址>`",
LangJapanese: "使い方: `/workspace init <git-url>`",
LangSpanish: "Uso: `/workspace init <git-url>`",
LangEnglish: "Usage: `/workspace init <git-url or directory-path>`",
LangChinese: "用法: `/workspace init <git仓库地址或目录路径>`",
LangTraditionalChinese: "用法: `/workspace init <git倉庫地址或目錄路徑>`",
LangJapanese: "使い方: `/workspace init <git-urlまたはディレクトリパス>`",
LangSpanish: "Uso: `/workspace init <git-url o ruta-de-directorio>`",
},
MsgWsBindUsage: {
LangEnglish: "Usage: `/workspace bind <workspace-name>`",
@@ -3658,11 +3660,11 @@ var messages = map[MsgKey]map[Language]string{
LangSpanish: "El workspace efectivo actual proviene de la capa shared. Usa `/workspace shared unbind` para quitarlo.",
},
MsgWsNotFoundHint: {
LangEnglish: "No workspace found for this channel. Send me a git repo URL to clone, or use `/workspace init <url>`.",
LangChinese: "此频道未找到工作区。请发送 git 仓库地址进行克隆,或使用 `/workspace init <仓库地址>`。",
LangTraditionalChinese: "此頻道未找到工作區。請發送 git 倉庫地址進行克隆,或使用 `/workspace init <倉庫地址>`。",
LangJapanese: "このチャンネルにワークスペースが見つかりません。gitリポジトリURLを送信するか、`/workspace init <url>` を使用してください。",
LangSpanish: "No se encontró workspace para este canal. Envía una URL de repo git para clonar, o usa `/workspace init <url>`.",
LangEnglish: "No workspace found for this channel. Send a git repo URL, a local directory path, or use `/workspace init <url-or-path>`.",
LangChinese: "此频道未找到工作区。请发送 git 仓库地址或本地目录路径,或使用 `/workspace init <仓库地址或目录路径>`。",
LangTraditionalChinese: "此頻道未找到工作區。請發送 git 倉庫地址或本地目錄路徑,或使用 `/workspace init <倉庫地址或目錄路徑>`。",
LangJapanese: "このチャンネルにワークスペースが見つかりません。git URL またはローカルディレクトリパスを送信するか、`/workspace init <urlまたはパス>` を使用してください。",
LangSpanish: "No se encontró workspace para este canal. Envía una URL de repo git, una ruta de directorio local, o usa `/workspace init <url-o-ruta>`.",
},
MsgWsResolutionError: {
LangEnglish: "Workspace resolution error: %v",
@@ -3692,6 +3694,20 @@ var messages = map[MsgKey]map[Language]string{
LangJapanese: "❌ リポジトリのクローンに失敗しました: %v",
LangSpanish: "❌ Error al clonar repositorio: %v",
},
MsgWsInitDirNotFound: {
LangEnglish: "Directory not found: `%s`. Please provide a valid directory path or a git URL.",
LangChinese: "目录不存在: `%s`。请提供有效的目录路径或 git 仓库地址。",
LangTraditionalChinese: "目錄不存在: `%s`。請提供有效的目錄路徑或 git 倉庫地址。",
LangJapanese: "ディレクトリが見つかりません: `%s`。有効なディレクトリパスまたは git URL を指定してください。",
LangSpanish: "Directorio no encontrado: `%s`. Proporcione una ruta de directorio válida o una URL de git.",
},
MsgWsInitInvalidTarget: {
LangEnglish: "Please provide a git URL (e.g. `https://github.com/org/repo`) or a local directory path.",
LangChinese: "请提供 git 仓库地址(如 `https://github.com/org/repo`)或本地目录路径。",
LangTraditionalChinese: "請提供 git 倉庫地址(如 `https://github.com/org/repo`)或本地目錄路徑。",
LangJapanese: "git URL(例: `https://github.com/org/repo`)またはローカルディレクトリパスを指定してください。",
LangSpanish: "Proporcione una URL de git (ej. `https://github.com/org/repo`) o una ruta de directorio local.",
},
}
func (i *I18n) T(key MsgKey) string {
+29
View File
@@ -396,6 +396,35 @@ func TestLooksLikeGitURL(t *testing.T) {
}
}
func TestLooksLikeLocalDir(t *testing.T) {
valid := []string{
"/absolute/path",
"~/home/project",
"./relative",
"../parent",
"my-project",
"byted-sheet",
}
for _, s := range valid {
if !looksLikeLocalDir(s) {
t.Errorf("looksLikeLocalDir(%q) = false, want true", s)
}
}
invalid := []string{
"",
"https://github.com/org/repo",
"git@github.com:org/repo.git",
"ssh://git@github.com/org/repo",
"http://example.com",
}
for _, s := range invalid {
if looksLikeLocalDir(s) {
t.Errorf("looksLikeLocalDir(%q) = true, want false", s)
}
}
}
func TestWorkspaceInitFlow_SlashCommandCleansUpExistingFlow(t *testing.T) {
baseDir := t.TempDir()
e := newTestEngineWithMultiWorkspace(t, baseDir)