Skip to content

Commit 5a609f8

Browse files
committed
Add git worktree support for workspace and config discovery
- Add new eca.git namespace with utilities for detecting git roots and worktrees - Update config loading to fall back to git root when no workspace folders provided - Update initialize handler to use git root as workspace folder fallback - Fix test isolation by mocking config/get-env in provider-api-key test - Add comprehensive tests for git utilities and config fallback behavior Signed-off-by: Arthur Heymans <[email protected]>
1 parent f5d5e4f commit 5a609f8

File tree

7 files changed

+195
-15
lines changed

7 files changed

+195
-15
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
- Fix deepseek reasoning with openai-chat API #228
88
- Support `~` in dynamic string parser.
99
- Support removing nullable values from LLM request body if the value in extraPayload is null. #232
10+
- Add git worktree support: automatically detect and use git repository root as workspace folder when not explicitly provided.
1011

1112
## 0.86.0
1213

src/eca/config.clj

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
[clojure.string :as string]
1818
[clojure.walk :as walk]
1919
[eca.logger :as logger]
20+
[eca.git :as git]
2021
[eca.messenger :as messenger]
2122
[eca.secrets :as secrets]
2223
[eca.shared :as shared :refer [multi-str]])
@@ -250,17 +251,21 @@
250251
(parse-dynamic-string-values (global-config-dir))))))
251252

252253
(defn ^:private config-from-local-file [roots]
253-
(reduce
254-
(fn [final-config {:keys [uri]}]
255-
(merge
256-
final-config
257-
(let [config-dir (io/file (shared/uri->filename uri) ".eca")
258-
config-file (io/file config-dir "config.json")]
259-
(when (.exists config-file)
260-
(some-> (safe-read-json-string (slurp config-file) (var *local-config-error*))
261-
(parse-dynamic-string-values config-dir))))))
262-
{}
263-
roots))
254+
(let [roots (if (seq roots)
255+
roots
256+
(when-let [git-root (git/root)]
257+
[{:uri (shared/filename->uri git-root)}]))]
258+
(reduce
259+
(fn [final-config {:keys [uri]}]
260+
(merge
261+
final-config
262+
(let [config-dir (io/file (shared/uri->filename uri) ".eca")
263+
config-file (io/file config-dir "config.json")]
264+
(when (.exists config-file)
265+
(some-> (safe-read-json-string (slurp config-file) (var *local-config-error*))
266+
(parse-dynamic-string-values config-dir))))))
267+
{}
268+
roots)))
264269

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

src/eca/git.clj

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
(ns eca.git
2+
(:require
3+
[clojure.java.shell :as shell]
4+
[clojure.string :as string]
5+
[eca.logger :as logger]))
6+
7+
(set! *warn-on-reflection* true)
8+
9+
(defn ^:private git-command
10+
"Execute a git command and return the trimmed output if successful."
11+
[& args]
12+
(try
13+
(let [{:keys [out exit err]} (apply shell/sh "git" args)]
14+
(when (= 0 exit)
15+
(string/trim out))
16+
(when-not (= 0 exit)
17+
(logger/debug "Git command failed:" args "Error:" err))
18+
(when (= 0 exit)
19+
(string/trim out)))
20+
(catch Exception e
21+
(logger/debug "Git command exception:" (ex-message e))
22+
nil)))
23+
24+
(defn root
25+
"Get the top-level directory of the current git repository or worktree.
26+
Returns the absolute path to the working directory root, which is the correct
27+
location for finding .eca config files in both regular repos and worktrees."
28+
[]
29+
(git-command "rev-parse" "--show-toplevel"))
30+
31+
(defn in-worktree?
32+
"Check if the current directory is in a git worktree (not the main working tree)."
33+
[]
34+
(when-let [git-dir (git-command "rev-parse" "--git-dir")]
35+
(and git-dir
36+
(string/includes? git-dir "/worktrees/"))))
37+
38+
(defn main-repo-root
39+
"Get the root of the main repository (not the worktree).
40+
This is useful if you need to access the main repo's directory.
41+
Returns nil if not in a git repository or if already in the main repo."
42+
[]
43+
(when-let [common-dir (git-command "rev-parse" "--git-common-dir")]
44+
(when (not= common-dir ".git")
45+
;; Common dir points to the main .git directory
46+
;; Get the parent directory of .git and then get its toplevel
47+
(let [parent-dir (.getParent (clojure.java.io/file common-dir))]
48+
(git-command "-C" parent-dir "rev-parse" "--show-toplevel")))))

src/eca/handlers.clj

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
(ns eca.handlers
22
(:require
3+
[clojure.java.io :as io]
4+
[clojure.string :as string]
35
[eca.config :as config]
46
[eca.db :as db]
57
[eca.features.chat :as f.chat]
@@ -9,6 +11,7 @@
911
[eca.features.rewrite :as f.rewrite]
1012
[eca.features.tools :as f.tools]
1113
[eca.features.tools.mcp :as f.mcp]
14+
[eca.git :as git]
1215
[eca.logger :as logger]
1316
[eca.messenger :as messenger]
1417
[eca.metrics :as metrics]
@@ -20,18 +23,33 @@
2023
(defn initialize [{:keys [db* metrics]} params]
2124
(metrics/task metrics :eca/initialize
2225
(reset! config/initialization-config* (shared/map->camel-cased-map (:initialization-options params)))
23-
(let [config (config/all @db*)]
26+
(let [config (config/all @db*)
27+
workspace-folders (or (:workspace-folders params)
28+
(when-some [root-uri (or (:root-uri params)
29+
(when-let [root-path (:root-path params)]
30+
(shared/filename->uri root-path)))]
31+
[{:name "root" :uri root-uri}])
32+
(when-let [git-root (git/root)]
33+
(let [folder-name (.getName (io/file git-root))]
34+
(logger/info "No workspace folders provided, using git root as fallback:"
35+
git-root
36+
(when (git/in-worktree?)
37+
"(worktree)"))
38+
[{:name folder-name
39+
:uri (shared/filename->uri git-root)}])))]
2440
(logger/debug "Considered config: " config)
41+
(logger/debug "Workspace folders: " workspace-folders)
2542
(swap! db* assoc
2643
:client-info (:client-info params)
27-
:workspace-folders (:workspace-folders params)
44+
:workspace-folders workspace-folders
2845
:client-capabilities (:capabilities params))
2946
(metrics/set-extra-metrics! db*)
3047
(when-not (:pureConfig config)
3148
(db/load-db-from-cache! db* config metrics))
3249

3350
{:chat-welcome-message (or (:welcomeMessage (:chat config)) ;;legacy
34-
(:welcomeMessage config))})))
51+
(:welcomeMessage config))})))
52+
3553

3654
(defn initialized [{:keys [db* messenger config metrics]}]
3755
(metrics/task metrics :eca/initialized

test/eca/config_test.clj

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
[clojure.java.io :as io]
55
[clojure.test :refer [deftest is testing]]
66
[eca.config :as config]
7+
[eca.git :as git]
78
[eca.logger :as logger]
89
[eca.secrets :as secrets]
910
[eca.test-helper :as h]
@@ -448,3 +449,19 @@
448449
nil))]
449450
(is (= "password1" (#'config/parse-dynamic-string "${netrc:api-gateway.example-corp.com}" "/tmp" {})))
450451
(is (= "password2" (#'config/parse-dynamic-string "${netrc:api_service.example.com}" "/tmp" {}))))))
452+
453+
(deftest git-worktree-config-fallback-test
454+
(testing "config-from-local-file uses git root as fallback when no roots provided"
455+
(with-redefs [git/root (constantly "/tmp/test-repo")]
456+
(let [result (#'config/config-from-local-file [])]
457+
;; Should attempt to read from git root even when roots is empty
458+
;; We can't test the actual file reading without mocking IO,
459+
;; but we can verify the function doesn't error and returns a map
460+
(is (map? result)))))
461+
462+
(testing "config-from-local-file prefers provided roots over git fallback"
463+
(with-redefs [git/root (constantly "/tmp/git-root")]
464+
(let [result (#'config/config-from-local-file [{:uri "file:///tmp/workspace"}])]
465+
;; Should use provided roots, not git fallback
466+
;; Again, just verify no errors
467+
(is (map? result))))))

test/eca/git_test.clj

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
(ns eca.git-test
2+
(:require
3+
[clojure.java.io :as io]
4+
[clojure.java.shell :as shell]
5+
[clojure.string :as string]
6+
[clojure.test :refer [deftest is testing]]
7+
[eca.git :as git]))
8+
9+
(set! *warn-on-reflection* true)
10+
11+
(deftest root-test
12+
(testing "git root returns a path when in a git repository"
13+
(let [result (git/root)]
14+
;; Should return a non-empty string path or nil
15+
(is (or (nil? result)
16+
(and (string? result)
17+
(not (empty? result)))))
18+
19+
;; If we have a root, it should be an absolute path
20+
(when result
21+
(is (.isAbsolute (io/file result)))))))
22+
23+
(deftest in-worktree-test
24+
(testing "in-worktree? returns a boolean"
25+
(let [result (git/in-worktree?)]
26+
(is (or (true? result)
27+
(false? result)
28+
(nil? result)))))
29+
30+
(testing "correctly detects worktree by checking git-dir"
31+
;; If we're in a worktree, git-dir should contain /worktrees/
32+
(when (git/in-worktree?)
33+
(let [{:keys [out exit]} (shell/sh "git" "rev-parse" "--git-dir")]
34+
(when (= 0 exit)
35+
(is (string/includes? out "worktrees")))))))
36+
37+
(deftest main-repo-root-test
38+
(testing "main-repo-root returns a path or nil"
39+
(let [result (git/main-repo-root)]
40+
(is (or (nil? result)
41+
(and (string? result)
42+
(not (empty? result)))))
43+
44+
;; If we have a main repo root, it should be absolute
45+
(when result
46+
(is (.isAbsolute (io/file result)))))))
47+
48+
(deftest integration-test
49+
(testing "git functions work together logically"
50+
(let [root (git/root)]
51+
(if root
52+
(do
53+
;; If we have a git root, we're in a git repo
54+
(is (string? root))
55+
(is (.exists (io/file root)))
56+
57+
;; If we're in a worktree, behavior should be consistent
58+
(if (git/in-worktree?)
59+
(testing "in a worktree"
60+
(let [main-root (git/main-repo-root)]
61+
;; Main repo root should exist
62+
(is (some? main-root))
63+
(when main-root
64+
;; Main root and worktree root should be different
65+
(is (not= root main-root))
66+
;; Both should exist
67+
(is (.exists (io/file main-root))))))
68+
69+
(testing "in main repo (not a worktree)"
70+
;; In main repo, main-repo-root might be nil or same as root
71+
(let [main-root (git/main-repo-root)]
72+
(is (or (nil? main-root)
73+
(= root main-root)))))))
74+
;; If not in a git repo, verify that git/root returns nil
75+
(is (nil? root))))))
76+
77+
(deftest worktree-config-discovery-test
78+
(testing "git root is suitable for .eca config discovery"
79+
(let [root (git/root)]
80+
(if root
81+
;; The root should be a directory where .eca config could exist
82+
(let [eca-dir (io/file root ".eca")]
83+
;; We don't test if .eca exists, just that the parent dir is valid
84+
(is (.isDirectory (io/file root)))
85+
86+
;; If .eca exists, it should be in the worktree root (not main repo)
87+
(when (.exists eca-dir)
88+
(is (.isDirectory eca-dir))))
89+
;; If not in a git repo, verify that git/root returns nil
90+
(is (nil? root))))))

test/eca/llm_util_test.clj

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,8 @@
132132
(spit temp-path (str "machine api.anthropic.com\nlogin work\npassword sk-ant-work-key\n\n"
133133
"machine api.anthropic.com\nlogin personal\npassword sk-ant-personal-key\n"))
134134

135-
(with-redefs [secrets/credential-file-paths (constantly [temp-path])]
135+
(with-redefs [secrets/credential-file-paths (constantly [temp-path])
136+
config/get-env (constantly nil)]
136137
;; Test with specific login
137138
(let [config {:providers
138139
{"anthropic" {:keyRc "[email protected]"}}}

0 commit comments

Comments
 (0)