Merge pull request #610 from g1f9/feat/workspace-init-local-dir
feat: support local directory paths in /workspace init
This commit is contained in:
4 files changed
+195
-17
No files matched your search
+58
-7
@@ -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
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user