Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
- Fix deepseek reasoning with openai-chat API #228
- Support `~` in dynamic string parser.
- Support removing nullable values from LLM request body if the value in extraPayload is null. #232
- Add git worktree support: automatically detect and use git repository root as workspace folder when not explicitly provided.

## 0.86.0

Expand Down
27 changes: 16 additions & 11 deletions src/eca/config.clj
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
[clojure.string :as string]
[clojure.walk :as walk]
[eca.logger :as logger]
[eca.git :as git]
[eca.messenger :as messenger]
[eca.secrets :as secrets]
[eca.shared :as shared :refer [multi-str]])
Expand Down Expand Up @@ -250,17 +251,21 @@
(parse-dynamic-string-values (global-config-dir))))))

(defn ^:private config-from-local-file [roots]
(reduce
(fn [final-config {:keys [uri]}]
(merge
final-config
(let [config-dir (io/file (shared/uri->filename uri) ".eca")
config-file (io/file config-dir "config.json")]
(when (.exists config-file)
(some-> (safe-read-json-string (slurp config-file) (var *local-config-error*))
(parse-dynamic-string-values config-dir))))))
{}
roots))
(let [roots (if (seq roots)
roots
(when-let [git-root (git/root)]
[{:uri (shared/filename->uri git-root)}]))]
(reduce
(fn [final-config {:keys [uri]}]
(merge
final-config
(let [config-dir (io/file (shared/uri->filename uri) ".eca")
config-file (io/file config-dir "config.json")]
(when (.exists config-file)
(some-> (safe-read-json-string (slurp config-file) (var *local-config-error*))
(parse-dynamic-string-values config-dir))))))
{}
roots)))

(def initialization-config* (atom {}))

Expand Down
48 changes: 48 additions & 0 deletions src/eca/git.clj
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
(ns eca.git
(:require
[clojure.java.shell :as shell]
[clojure.string :as string]
[eca.logger :as logger]))

(set! *warn-on-reflection* true)

(defn ^:private git-command
"Execute a git command and return the trimmed output if successful."
[& args]
(try
(let [{:keys [out exit err]} (apply shell/sh "git" args)]
(when (= 0 exit)
(string/trim out))
(when-not (= 0 exit)
(logger/debug "Git command failed:" args "Error:" err))
(when (= 0 exit)
(string/trim out)))
(catch Exception e
(logger/debug "Git command exception:" (ex-message e))
nil)))

(defn root
"Get the top-level directory of the current git repository or worktree.
Returns the absolute path to the working directory root, which is the correct
location for finding .eca config files in both regular repos and worktrees."
[]
(git-command "rev-parse" "--show-toplevel"))

(defn in-worktree?
"Check if the current directory is in a git worktree (not the main working tree)."
[]
(when-let [git-dir (git-command "rev-parse" "--git-dir")]
(and git-dir
(string/includes? git-dir "/worktrees/"))))

(defn main-repo-root
"Get the root of the main repository (not the worktree).
This is useful if you need to access the main repo's directory.
Returns nil if not in a git repository or if already in the main repo."
[]
(when-let [common-dir (git-command "rev-parse" "--git-common-dir")]
(when (not= common-dir ".git")
;; Common dir points to the main .git directory
;; Get the parent directory of .git and then get its toplevel
(let [parent-dir (.getParent (clojure.java.io/file common-dir))]
(git-command "-C" parent-dir "rev-parse" "--show-toplevel")))))
24 changes: 21 additions & 3 deletions src/eca/handlers.clj
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
(ns eca.handlers
(:require
[clojure.java.io :as io]
[clojure.string :as string]
[eca.config :as config]
[eca.db :as db]
[eca.features.chat :as f.chat]
Expand All @@ -9,6 +11,7 @@
[eca.features.rewrite :as f.rewrite]
[eca.features.tools :as f.tools]
[eca.features.tools.mcp :as f.mcp]
[eca.git :as git]
[eca.logger :as logger]
[eca.messenger :as messenger]
[eca.metrics :as metrics]
Expand All @@ -20,18 +23,33 @@
(defn initialize [{:keys [db* metrics]} params]
(metrics/task metrics :eca/initialize
(reset! config/initialization-config* (shared/map->camel-cased-map (:initialization-options params)))
(let [config (config/all @db*)]
(let [config (config/all @db*)
workspace-folders (or (:workspace-folders params)
(when-some [root-uri (or (:root-uri params)
(when-let [root-path (:root-path params)]
(shared/filename->uri root-path)))]
[{:name "root" :uri root-uri}])
(when-let [git-root (git/root)]
(let [folder-name (.getName (io/file git-root))]
(logger/info "No workspace folders provided, using git root as fallback:"
git-root
(when (git/in-worktree?)
"(worktree)"))
[{:name folder-name
:uri (shared/filename->uri git-root)}])))]
(logger/debug "Considered config: " config)
(logger/debug "Workspace folders: " workspace-folders)
(swap! db* assoc
:client-info (:client-info params)
:workspace-folders (:workspace-folders params)
:workspace-folders workspace-folders
:client-capabilities (:capabilities params))
(metrics/set-extra-metrics! db*)
(when-not (:pureConfig config)
(db/load-db-from-cache! db* config metrics))

{:chat-welcome-message (or (:welcomeMessage (:chat config)) ;;legacy
(:welcomeMessage config))})))
(:welcomeMessage config))})))


(defn initialized [{:keys [db* messenger config metrics]}]
(metrics/task metrics :eca/initialized
Expand Down
17 changes: 17 additions & 0 deletions test/eca/config_test.clj
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
[clojure.java.io :as io]
[clojure.test :refer [deftest is testing]]
[eca.config :as config]
[eca.git :as git]
[eca.logger :as logger]
[eca.secrets :as secrets]
[eca.test-helper :as h]
Expand Down Expand Up @@ -448,3 +449,19 @@
nil))]
(is (= "password1" (#'config/parse-dynamic-string "${netrc:api-gateway.example-corp.com}" "/tmp" {})))
(is (= "password2" (#'config/parse-dynamic-string "${netrc:api_service.example.com}" "/tmp" {}))))))

(deftest git-worktree-config-fallback-test
(testing "config-from-local-file uses git root as fallback when no roots provided"
(with-redefs [git/root (constantly "/tmp/test-repo")]
(let [result (#'config/config-from-local-file [])]
;; Should attempt to read from git root even when roots is empty
;; We can't test the actual file reading without mocking IO,
;; but we can verify the function doesn't error and returns a map
(is (map? result)))))

(testing "config-from-local-file prefers provided roots over git fallback"
(with-redefs [git/root (constantly "/tmp/git-root")]
(let [result (#'config/config-from-local-file [{:uri "file:///tmp/workspace"}])]
;; Should use provided roots, not git fallback
;; Again, just verify no errors
(is (map? result))))))
90 changes: 90 additions & 0 deletions test/eca/git_test.clj
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
(ns eca.git-test
(:require
[clojure.java.io :as io]
[clojure.java.shell :as shell]
[clojure.string :as string]
[clojure.test :refer [deftest is testing]]
[eca.git :as git]))

(set! *warn-on-reflection* true)

(deftest root-test
(testing "git root returns a path when in a git repository"
(let [result (git/root)]
;; Should return a non-empty string path or nil
(is (or (nil? result)
(and (string? result)
(not (empty? result)))))

;; If we have a root, it should be an absolute path
(when result
(is (.isAbsolute (io/file result)))))))

(deftest in-worktree-test
(testing "in-worktree? returns a boolean"
(let [result (git/in-worktree?)]
(is (or (true? result)
(false? result)
(nil? result)))))

(testing "correctly detects worktree by checking git-dir"
;; If we're in a worktree, git-dir should contain /worktrees/
(when (git/in-worktree?)
(let [{:keys [out exit]} (shell/sh "git" "rev-parse" "--git-dir")]
(when (= 0 exit)
(is (string/includes? out "worktrees")))))))

(deftest main-repo-root-test
(testing "main-repo-root returns a path or nil"
(let [result (git/main-repo-root)]
(is (or (nil? result)
(and (string? result)
(not (empty? result)))))

;; If we have a main repo root, it should be absolute
(when result
(is (.isAbsolute (io/file result)))))))

(deftest integration-test
(testing "git functions work together logically"
(let [root (git/root)]
(if root
(do
;; If we have a git root, we're in a git repo
(is (string? root))
(is (.exists (io/file root)))

;; If we're in a worktree, behavior should be consistent
(if (git/in-worktree?)
(testing "in a worktree"
(let [main-root (git/main-repo-root)]
;; Main repo root should exist
(is (some? main-root))
(when main-root
;; Main root and worktree root should be different
(is (not= root main-root))
;; Both should exist
(is (.exists (io/file main-root))))))

(testing "in main repo (not a worktree)"
;; In main repo, main-repo-root might be nil or same as root
(let [main-root (git/main-repo-root)]
(is (or (nil? main-root)
(= root main-root)))))))
;; If not in a git repo, verify that git/root returns nil
(is (nil? root))))))

(deftest worktree-config-discovery-test
(testing "git root is suitable for .eca config discovery"
(let [root (git/root)]
(if root
;; The root should be a directory where .eca config could exist
(let [eca-dir (io/file root ".eca")]
;; We don't test if .eca exists, just that the parent dir is valid
(is (.isDirectory (io/file root)))

;; If .eca exists, it should be in the worktree root (not main repo)
(when (.exists eca-dir)
(is (.isDirectory eca-dir))))
;; If not in a git repo, verify that git/root returns nil
(is (nil? root))))))
3 changes: 2 additions & 1 deletion test/eca/llm_util_test.clj
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,8 @@
(spit temp-path (str "machine api.anthropic.com\nlogin work\npassword sk-ant-work-key\n\n"
"machine api.anthropic.com\nlogin personal\npassword sk-ant-personal-key\n"))

(with-redefs [secrets/credential-file-paths (constantly [temp-path])]
(with-redefs [secrets/credential-file-paths (constantly [temp-path])
config/get-env (constantly nil)]
;; Test with specific login
(let [config {:providers
{"anthropic" {:keyRc "[email protected]"}}}
Expand Down
Loading