@nbardy/oompa 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,355 @@
1
+ (ns agentnet.merge
2
+ "Git merge operations for integrating approved changes.
3
+
4
+ After a task passes review, its worktree branch needs to be merged
5
+ into main. This module handles:
6
+ - Fast-forward merges (ideal case)
7
+ - Merge commits (divergent histories)
8
+ - Conflict detection and resolution
9
+ - Rollback on failure
10
+
11
+ Design:
12
+ - Merges happen in main repo, not worktrees
13
+ - Conflicts can be auto-resolved or bounced back
14
+ - Each merge is atomic (succeed or rollback)
15
+ - Merge results are logged for audit"
16
+ (:require [agentnet.schema :as schema]
17
+ [agentnet.worktree :as worktree]
18
+ [babashka.process :as process]
19
+ [clojure.java.io :as io]
20
+ [clojure.string :as str]))
21
+
22
+ ;; =============================================================================
23
+ ;; Function Specs
24
+ ;; =============================================================================
25
+
26
+ ;; merge-branch! : GitBranch, MergeStrategy, Options -> MergeResult
27
+ ;; Merge a branch into current branch (main)
28
+
29
+ ;; detect-conflicts : GitBranch -> [FilePath]
30
+ ;; Check what files would conflict if merged
31
+
32
+ ;; resolve-conflicts! : ConflictResolution -> Boolean
33
+ ;; Attempt to resolve merge conflicts
34
+
35
+ ;; abort-merge! : -> nil
36
+ ;; Abort an in-progress merge
37
+
38
+ ;; =============================================================================
39
+ ;; Git Helpers
40
+ ;; =============================================================================
41
+
42
+ (defn- git
43
+ "Run git command, return {:exit :out :err}"
44
+ [& args]
45
+ (let [cmd (into ["git"] args)
46
+ {:keys [exit out err]} (process/sh cmd {:out :string :err :string})]
47
+ {:exit exit
48
+ :out (str/trim (or out ""))
49
+ :err (str/trim (or err ""))}))
50
+
51
+ (defn- git!
52
+ "Run git command, throw on failure"
53
+ [& args]
54
+ (let [{:keys [exit out err]} (apply git args)]
55
+ (if (zero? exit)
56
+ out
57
+ (throw (ex-info (str "git failed: " err)
58
+ {:command args :exit exit :err err})))))
59
+
60
+ (defn- current-branch []
61
+ (git! "rev-parse" "--abbrev-ref" "HEAD"))
62
+
63
+ (defn- current-sha []
64
+ (git! "rev-parse" "HEAD"))
65
+
66
+ (defn- branch-sha [branch]
67
+ (:out (git "rev-parse" branch)))
68
+
69
+ (defn- merge-in-progress? []
70
+ (.exists (io/file ".git/MERGE_HEAD")))
71
+
72
+ (defn- stash-changes! []
73
+ (let [{:keys [exit]} (git "stash" "push" "-m" "agentnet-auto-stash")]
74
+ (zero? exit)))
75
+
76
+ (defn- pop-stash! []
77
+ (git "stash" "pop"))
78
+
79
+ ;; =============================================================================
80
+ ;; Conflict Detection
81
+ ;; =============================================================================
82
+
83
+ (defn detect-conflicts
84
+ "Check what files would conflict if branch were merged.
85
+ Returns vector of file paths, empty if no conflicts."
86
+ [source-branch]
87
+ (let [;; Dry-run merge to detect conflicts
88
+ {:keys [exit out err]} (git "merge" "--no-commit" "--no-ff" source-branch)]
89
+ (if (zero? exit)
90
+ ;; No conflicts, abort the merge we started
91
+ (do (git "merge" "--abort")
92
+ [])
93
+ ;; Parse conflict files from output
94
+ (do
95
+ (git "merge" "--abort")
96
+ (->> (str/split-lines (str out "\n" err))
97
+ (filter #(re-find #"CONFLICT|Merge conflict" %))
98
+ (map #(second (re-find #"(?:in|Merge conflict in)\s+(.+)" %)))
99
+ (remove nil?)
100
+ vec)))))
101
+
102
+ (defn can-fast-forward?
103
+ "Check if source branch can be fast-forwarded into current branch"
104
+ [source-branch]
105
+ (let [current (current-sha)
106
+ merge-base (:out (git "merge-base" (current-branch) source-branch))]
107
+ (= current merge-base)))
108
+
109
+ ;; =============================================================================
110
+ ;; Merge Strategies
111
+ ;; =============================================================================
112
+
113
+ (defmulti execute-merge
114
+ "Execute merge with specified strategy"
115
+ (fn [source-branch strategy _opts] strategy))
116
+
117
+ (defmethod execute-merge :fast-forward
118
+ [source-branch _ _opts]
119
+ (let [{:keys [exit err]} (git "merge" "--ff-only" source-branch)]
120
+ (if (zero? exit)
121
+ {:status :merged
122
+ :source-branch source-branch
123
+ :target-branch (current-branch)
124
+ :commit-sha (current-sha)}
125
+ {:status :failed
126
+ :source-branch source-branch
127
+ :target-branch (current-branch)
128
+ :error (str "Fast-forward not possible: " err)})))
129
+
130
+ (defmethod execute-merge :no-ff
131
+ [source-branch _ {:keys [message]}]
132
+ (let [msg (or message (str "Merge branch '" source-branch "'"))
133
+ {:keys [exit err]} (git "merge" "--no-ff" "-m" msg source-branch)]
134
+ (if (zero? exit)
135
+ {:status :merged
136
+ :source-branch source-branch
137
+ :target-branch (current-branch)
138
+ :commit-sha (current-sha)}
139
+ {:status :conflict
140
+ :source-branch source-branch
141
+ :target-branch (current-branch)
142
+ :conflicts (detect-conflicts source-branch)
143
+ :error err})))
144
+
145
+ (defmethod execute-merge :squash
146
+ [source-branch _ {:keys [message]}]
147
+ (let [{:keys [exit err]} (git "merge" "--squash" source-branch)]
148
+ (if (zero? exit)
149
+ ;; Squash stages but doesn't commit
150
+ (let [msg (or message (str "Squash merge: " source-branch))
151
+ {:keys [exit]} (git "commit" "-m" msg)]
152
+ (if (zero? exit)
153
+ {:status :merged
154
+ :source-branch source-branch
155
+ :target-branch (current-branch)
156
+ :commit-sha (current-sha)}
157
+ {:status :failed
158
+ :source-branch source-branch
159
+ :target-branch (current-branch)
160
+ :error "Squash commit failed"}))
161
+ {:status :conflict
162
+ :source-branch source-branch
163
+ :target-branch (current-branch)
164
+ :error err})))
165
+
166
+ (defmethod execute-merge :rebase
167
+ [source-branch _ _opts]
168
+ ;; Rebase source onto current, then fast-forward
169
+ (let [current (current-branch)
170
+ {:keys [exit err]} (git "rebase" current source-branch)]
171
+ (if (zero? exit)
172
+ ;; Switch back and fast-forward
173
+ (do
174
+ (git! "checkout" current)
175
+ (execute-merge source-branch :fast-forward {}))
176
+ (do
177
+ (git "rebase" "--abort")
178
+ {:status :conflict
179
+ :source-branch source-branch
180
+ :target-branch current
181
+ :error (str "Rebase failed: " err)}))))
182
+
183
+ (defmethod execute-merge :default
184
+ [source-branch strategy _opts]
185
+ (throw (ex-info (str "Unknown merge strategy: " strategy)
186
+ {:strategy strategy :source-branch source-branch})))
187
+
188
+ ;; =============================================================================
189
+ ;; Conflict Resolution
190
+ ;; =============================================================================
191
+
192
+ (defn resolve-conflicts!
193
+ "Attempt to resolve merge conflicts.
194
+
195
+ Strategies:
196
+ :ours - Accept current branch version
197
+ :theirs - Accept incoming branch version
198
+ :manual - Leave for human intervention
199
+ :abort - Abort the merge entirely"
200
+ [resolution]
201
+ (when-not (merge-in-progress?)
202
+ (throw (ex-info "No merge in progress" {})))
203
+
204
+ (case resolution
205
+ :ours
206
+ (do
207
+ (git! "checkout" "--ours" ".")
208
+ (git! "add" "-A")
209
+ (let [{:keys [exit]} (git "commit" "--no-edit")]
210
+ (zero? exit)))
211
+
212
+ :theirs
213
+ (do
214
+ (git! "checkout" "--theirs" ".")
215
+ (git! "add" "-A")
216
+ (let [{:keys [exit]} (git "commit" "--no-edit")]
217
+ (zero? exit)))
218
+
219
+ :abort
220
+ (do
221
+ (git! "merge" "--abort")
222
+ false)
223
+
224
+ :manual
225
+ false ; Leave conflicts for human
226
+
227
+ (throw (ex-info (str "Unknown resolution: " resolution)
228
+ {:resolution resolution}))))
229
+
230
+ (defn abort-merge!
231
+ "Abort an in-progress merge"
232
+ []
233
+ (when (merge-in-progress?)
234
+ (git! "merge" "--abort")))
235
+
236
+ ;; =============================================================================
237
+ ;; Main API
238
+ ;; =============================================================================
239
+
240
+ (defn merge-branch!
241
+ "Merge a branch into current branch.
242
+
243
+ Arguments:
244
+ source-branch - Branch to merge from
245
+ strategy - :fast-forward, :no-ff, :squash, :rebase
246
+ opts - {:message str, :conflict-resolution keyword, :dry-run bool}
247
+
248
+ Returns MergeResult"
249
+ [source-branch strategy opts]
250
+ (schema/assert-valid schema/git-branch? source-branch "source-branch")
251
+ (schema/assert-valid schema/merge-strategy? strategy "strategy")
252
+
253
+ (if (:dry-run opts)
254
+ (let [conflicts (detect-conflicts source-branch)]
255
+ {:status (if (empty? conflicts) :would-merge :would-conflict)
256
+ :source-branch source-branch
257
+ :target-branch (current-branch)
258
+ :conflicts conflicts})
259
+ ;; else: actually merge
260
+ (let [result (execute-merge source-branch strategy opts)]
261
+ ;; Handle conflicts if resolution strategy specified
262
+ (if (and (= :conflict (:status result))
263
+ (:conflict-resolution opts))
264
+ (if (resolve-conflicts! (:conflict-resolution opts))
265
+ (assoc result :status :merged :commit-sha (current-sha))
266
+ result)
267
+ result))))
268
+
269
+ (defn merge-worktree!
270
+ "Merge a worktree's branch into main.
271
+
272
+ Arguments:
273
+ worktree - Worktree map with :branch
274
+ opts - Merge options
275
+
276
+ Returns MergeResult"
277
+ [worktree opts]
278
+ (let [{:keys [branch]} worktree
279
+ strategy (or (:strategy opts) :no-ff)
280
+ message (or (:message opts)
281
+ (format "Merge %s: %s"
282
+ (:id worktree)
283
+ (or (:current-task worktree) "task")))]
284
+ (merge-branch! branch strategy (assoc opts :message message))))
285
+
286
+ ;; =============================================================================
287
+ ;; Safe Merge with Rollback
288
+ ;; =============================================================================
289
+
290
+ (defn safe-merge!
291
+ "Merge with automatic rollback on failure.
292
+
293
+ Creates a backup ref before merge, restores on failure."
294
+ [source-branch strategy opts]
295
+ (let [backup-ref (str "refs/agentnet/backup/" (System/currentTimeMillis))
296
+ original-sha (current-sha)]
297
+ ;; Create backup
298
+ (git! "update-ref" backup-ref original-sha)
299
+
300
+ (try
301
+ (let [result (merge-branch! source-branch strategy opts)]
302
+ (if (#{:merged} (:status result))
303
+ (do
304
+ ;; Clean up backup on success
305
+ (git "update-ref" "-d" backup-ref)
306
+ result)
307
+ (do
308
+ ;; Rollback on failure
309
+ (git! "reset" "--hard" original-sha)
310
+ (git "update-ref" "-d" backup-ref)
311
+ result)))
312
+ (catch Exception e
313
+ ;; Rollback on exception
314
+ (git! "reset" "--hard" original-sha)
315
+ (git "update-ref" "-d" backup-ref)
316
+ (throw e)))))
317
+
318
+ ;; =============================================================================
319
+ ;; Batch Merge
320
+ ;; =============================================================================
321
+
322
+ (defn merge-all!
323
+ "Merge multiple worktrees in sequence.
324
+
325
+ Stops on first conflict unless :continue-on-conflict is true.
326
+ Returns vector of MergeResults."
327
+ [worktrees opts]
328
+ (loop [remaining worktrees
329
+ results []]
330
+ (if-let [wt (first remaining)]
331
+ (let [result (safe-merge! (:branch wt) (or (:strategy opts) :no-ff) opts)]
332
+ (if (and (= :conflict (:status result))
333
+ (not (:continue-on-conflict opts)))
334
+ (conj results result) ; Stop on conflict
335
+ (recur (rest remaining) (conj results result))))
336
+ results)))
337
+
338
+ ;; =============================================================================
339
+ ;; Cleanup
340
+ ;; =============================================================================
341
+
342
+ (defn delete-merged-branch!
343
+ "Delete a branch that has been merged"
344
+ [branch]
345
+ (let [{:keys [exit]} (git "branch" "-d" branch)]
346
+ (zero? exit)))
347
+
348
+ (defn cleanup-after-merge!
349
+ "Clean up worktree and branch after successful merge"
350
+ [worktree pool]
351
+ (let [{:keys [id branch]} worktree]
352
+ ;; Release worktree (resets to main)
353
+ (worktree/release! pool id {:reset? true})
354
+ ;; Delete the merged branch
355
+ (delete-merged-branch! branch)))
@@ -0,0 +1,123 @@
1
+ (ns agentnet.notes
2
+ "Filesystem-based queue helpers for the agent_notes workflow."
3
+ (:require [clojure.java.io :as io]
4
+ [clojure.string :as str]))
5
+
6
+ (def NOTES
7
+ "Root directory that stores all note queues."
8
+ (io/file "agent_notes"))
9
+
10
+ (defn- fm-frontmatter
11
+ "Parse lightweight YAML-ish front-matter between --- lines.
12
+ Returns a map of keyword -> string, or {} when not present or malformed."
13
+ [^String s]
14
+ (try
15
+ (let [[_ block] (re-find #"(?s)^---\n(.*?)\n---\n" s)]
16
+ (if-not block
17
+ {}
18
+ (->> (str/split-lines block)
19
+ (keep #(when-let [[_ k v] (re-matches #"^\s*([A-Za-z0-9_]+)\s*:\s*(.+)\s*$" %)]
20
+ [(keyword k) (str/trim v)]))
21
+ (into {}))))
22
+ (catch Exception _
23
+ {})))
24
+
25
+ (defn- read-note
26
+ "Return a metadata map for note file `f` with parsed front-matter."
27
+ [^java.io.File f]
28
+ (let [s (slurp f)
29
+ fm (fm-frontmatter s)]
30
+ {:file (.getAbsolutePath f)
31
+ :name (.getName f)
32
+ :dir (.getName (.getParentFile f))
33
+ :mtime (.lastModified f)
34
+ :fm fm
35
+ :status (keyword (or (:status fm) "green"))
36
+ :id (or (:id fm)
37
+ (second (re-find #"__([^_]+)__" (.getName f))))
38
+ :targets (let [t (:targets fm)]
39
+ (when (and t (string? t) (not (str/blank? t)))
40
+ (let [parts (-> t
41
+ (str/replace #"[\[\]\"]" "")
42
+ (str/split #",\s*"))]
43
+ (->> parts
44
+ (remove str/blank?)
45
+ vec))))}))
46
+
47
+ (defn list-notes
48
+ "Return newest-first vector of note metadata maps from `subdir`."
49
+ [subdir]
50
+ (let [d (io/file NOTES subdir)]
51
+ (when (.exists d)
52
+ (->> (.listFiles d)
53
+ (filter (fn [^java.io.File f]
54
+ (and (.isFile f)
55
+ (not (.startsWith (.getName f) ".")))))
56
+ (map read-note)
57
+ (sort-by :mtime >)
58
+ vec))))
59
+
60
+ (defn green-ready
61
+ "Return newest-first notes from ready_for_review/ that are not marked red."
62
+ []
63
+ (->> (list-notes "ready_for_review")
64
+ (remove #(= (:status %) :red))))
65
+
66
+ (defn proposed-green
67
+ "Return green proposals sorted by rank when available."
68
+ []
69
+ (->> (list-notes "proposed_tasks")
70
+ (filter #(= (:status %) :green))
71
+ (sort-by (comp #(try
72
+ (Integer/parseInt %)
73
+ (catch Exception _
74
+ 9999))
75
+ :rank :fm))))
76
+
77
+ (defn cto-feedback-latest
78
+ "Return up to 20 newest feedback notes from notes_FROM_CTO/."
79
+ []
80
+ (take 20 (list-notes "notes_FROM_CTO")))
81
+
82
+ (defn scratch-latest
83
+ "Return scratch notes touched within RECENT-SEC seconds, newest first."
84
+ [recent-sec]
85
+ (let [cut (- (System/currentTimeMillis) (* 1000 recent-sec))]
86
+ (->> (list-notes "scratch")
87
+ (filter #(>= (:mtime %) cut))
88
+ (take 50)
89
+ vec)))
90
+
91
+ (defn pick-files-for-prompt
92
+ "Select distinct filenames that should be surfaced in agent prompts.
93
+ recent-sec defaults to 120 seconds when unspecified."
94
+ [{:keys [recent-sec]}]
95
+ (let [recent (or recent-sec 120)
96
+ a (map :name (take 10 (green-ready)))
97
+ b (map :name (take 10 (proposed-green)))
98
+ c (map :name (take 5 (cto-feedback-latest)))
99
+ d (map :name (take 5 (scratch-latest recent)))]
100
+ (->> (concat a b c d)
101
+ distinct
102
+ vec)))
103
+
104
+ (defn mark-note-red!
105
+ "Ensure the given note map is marked red in place.
106
+ Expects the note to have been produced by read-note."
107
+ [note]
108
+ (let [f (io/file (:file note))
109
+ s (slurp f)
110
+ [_ front body] (re-matches #"(?s)^---\n(.*?)\n---\n(.*)$" s)
111
+ ensure-trailing-newline (fn [text] (if (str/blank? text) "" (str text (when-not (str/ends-with? text "\n") "\n"))))]
112
+ (if front
113
+ (let [lines (str/split-lines front)
114
+ has-status? (some #(re-matches #"(?i)^\s*status\s*:" %) lines)
115
+ updated-lines (if has-status?
116
+ (map #(if (re-matches #"(?i)^\s*status\s*:" %)
117
+ "status: red"
118
+ %)
119
+ lines)
120
+ (conj (vec lines) "status: red"))
121
+ new-front (str/join "\n" updated-lines)]
122
+ (spit f (str "---\n" (ensure-trailing-newline new-front) "---\n" body)))
123
+ (spit f (str "---\nstatus: red\n---\n" s)))))