Skip to content

Commit 7962a52

Browse files
leifericfclaude
andcommitted
feat(p4): replace git-p4 wrapper with clj-p4 (cold turkey)
Delete git.clj:471-592 (the entire ;; --- Perforce via git-p4 --- block) and replace with src/noumenon/p4.clj — a thin adapter over the new pure-Clojure clj-p4 library. clj-p4 sits at ../clj-p4 via :local/root in deps.edn until v0.1.0 ships to Clojars. Touchpoints swapped to noumenon.p4: repo.clj/clone-or-reuse-p4, repo_manager.clj/url->db-name + register-repo! + refresh-repo!, sync.clj/branch-vcs + auto-sync-p4!. mcp.clj's noumenon_update description updated to mention Perforce streams. Behaviour change worth flagging for early-alpha users (none known yet): clj-p4 produces *bare* git repos rather than non-bare working trees. data/repos/<name>/HEAD now exists where data/repos/<name>/.git/ used to. Re-clone any pre-existing P4 imports to migrate. Tests: noumenon.p4-test covers depot-path detection, clone-name derivation, and validate-throws semantics. The lower-level parsing/clone/sync correctness lives in clj-p4's own suite (200 assertions there). All 630 noumenon tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent f04c6cf commit 7962a52

9 files changed

Lines changed: 193 additions & 213 deletions

File tree

deps.edn

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88
http-kit/http-kit {:mvn/version "2.8.0"}
99
org.clojure/tools.namespace {:mvn/version "1.5.0"}
1010
com.taoensso/nippy {:mvn/version "3.4.2"}
11-
integrant/integrant {:mvn/version "0.13.1"}}
11+
integrant/integrant {:mvn/version "0.13.1"}
12+
io.github.leifericf/clj-p4 {:local/root "../clj-p4"}}
1213

1314
:aliases
1415
{:test

src/noumenon/git.clj

Lines changed: 2 additions & 126 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
(ns noumenon.git
2-
(:require [clojure.edn :as edn]
3-
[clojure.java.io :as io]
2+
(:require [clojure.java.io :as io]
43
[clojure.java.shell :as shell]
54
[clojure.string :as str]
65
[datomic.client.api :as d]
@@ -91,7 +90,7 @@
9190
regex-based hostname extractor returns nil for those, so its
9291
`when-let` would otherwise short-circuit and leave the URL
9392
unchecked. Perforce depot paths follow a different code path
94-
(`p4-clone!`) and never reach this validator.
93+
(`noumenon.p4/clone!`) and never reach this validator.
9594
9695
Both throws carry `:status 400` so HTTP handlers render bad URLs as
9796
400, not as the route handler's default 500 fallback."
@@ -467,126 +466,3 @@
467466
{:commits-imported (count to-import)
468467
:commits-skipped skipped
469468
:elapsed-ms elapsed}))))
470-
471-
;; --- Perforce via git-p4 ---
472-
473-
(defn p4-depot-path?
474-
"True if s looks like a Perforce depot path (starts with //)."
475-
[s]
476-
(boolean (and (string? s) (str/starts-with? s "//"))))
477-
478-
(defn p4-available?
479-
"True if git-p4 is available (git p4 subcommand works)."
480-
[]
481-
(zero? (:exit (shell/sh "git" "p4" "--help"))))
482-
483-
(defn p4-clone?
484-
"True if repo-path is a git-p4 clone (has p4 remote refs)."
485-
[repo-path]
486-
(let [p4-refs (io/file repo-path ".git" "refs" "remotes" "p4")]
487-
(and (.isDirectory p4-refs)
488-
(pos? (count (.list p4-refs))))))
489-
490-
(defn p4-depot->clone-name
491-
"Derive a local clone directory name from a depot path.
492-
//depot/ProjectA/main/... -> ProjectA-main
493-
//stream/main/... -> main
494-
//depot/... -> depot"
495-
[depot-path]
496-
(let [segments (-> depot-path
497-
(str/replace #"^//" "")
498-
(str/replace #"/\.\.\.$" "")
499-
(str/replace #"/$" "")
500-
(str/split #"/"))
501-
;; Use sub-path segments if available, else fall back to depot name
502-
name-parts (if (> (count segments) 1) (rest segments) segments)]
503-
(-> (str/join "-" name-parts)
504-
(str/replace #"[^a-zA-Z0-9\-_.]" ""))))
505-
506-
(defn p4-clone-path
507-
"Local clone path for a depot path: data/repos/<derived-name>."
508-
[depot-path]
509-
(str "data/repos/" (p4-depot->clone-name depot-path)))
510-
511-
(def ^:private default-p4-excludes
512-
"Default binary exclusion patterns, loaded from p4-excludes.edn."
513-
(delay
514-
(some-> (io/resource "p4-excludes.edn") slurp edn/read-string)))
515-
516-
(defn p4-exclude-patterns
517-
"Compute the final exclusion pattern list given options.
518-
Options:
519-
:no-default-excludes? — skip default patterns (default false)
520-
:extra-excludes — additional patterns to exclude (vector of strings)
521-
:includes — patterns to remove from excludes (vector of strings)"
522-
[{:keys [no-default-excludes? extra-excludes includes]}]
523-
(let [defaults (when-not no-default-excludes?
524-
(->> (vals @default-p4-excludes) (apply concat)))
525-
all (concat defaults extra-excludes)
526-
include-set (set includes)]
527-
(->> all (remove include-set) distinct vec)))
528-
529-
(defn- p4-exclude-args
530-
"Build -/ arguments for git p4 clone from a list of exclusion patterns."
531-
[patterns]
532-
(mapcat (fn [p] ["-/" p]) patterns))
533-
534-
(defn validate-p4-depot-path!
535-
"Validate a Perforce depot path. Must start with //, contain only safe chars,
536-
and have at least one depot name segment. Throws on invalid input."
537-
[depot-path]
538-
(when-not (and (string? depot-path)
539-
(re-matches #"//[a-zA-Z0-9_\-./]+" depot-path)
540-
(not (str/includes? depot-path "..")))
541-
(throw (ex-info "Invalid Perforce depot path"
542-
{:depot-path depot-path
543-
:message "Depot path must start with // and contain only alphanumeric, dash, underscore, dot, or slash characters"})))
544-
(when-not (p4-available?)
545-
(throw (ex-info "git-p4 is not available. Install git-p4 to use Perforce support."
546-
{:depot-path depot-path}))))
547-
548-
(defn p4-clone!
549-
"Clone a Perforce depot path via git-p4 into target-dir.
550-
Options:
551-
:excludes — override exclusion patterns (vector of strings)
552-
:no-default-excludes? — skip default binary excludes
553-
:extra-excludes — additional patterns to exclude
554-
:includes — patterns to remove from default excludes
555-
:use-client-spec? — use P4 workspace view for filtering
556-
:max-changes — limit history depth (number of changelists)"
557-
[depot-path target-dir opts]
558-
(validate-p4-depot-path! depot-path)
559-
(let [excludes (or (:excludes opts)
560-
(p4-exclude-patterns opts))
561-
args (cond-> ["git" "p4" "clone"]
562-
(:use-client-spec? opts) (conj "--use-client-spec")
563-
(not (:use-client-spec? opts)) (into (p4-exclude-args excludes))
564-
(:max-changes opts) (conj (str "--max-changes=" (:max-changes opts)))
565-
true (conj (str depot-path "@all"))
566-
true (conj "--destination" (str target-dir)))
567-
_ (log! (str "git-p4: cloning " depot-path " into " target-dir
568-
" (" (count excludes) " exclusion patterns)"))
569-
{:keys [exit err]} (apply shell/sh args)]
570-
(when-not (zero? exit)
571-
(throw (ex-info (str "git p4 clone failed: " (str/trim (or err "")))
572-
{:exit exit :depot-path depot-path :target target-dir
573-
:stderr (when err (subs err 0 (min (count err) 500)))})))
574-
(log! "git-p4: clone complete")
575-
target-dir))
576-
577-
(defn p4-sync!
578-
"Sync a git-p4 clone with latest Perforce changelists.
579-
Runs `git p4 sync` then `git p4 rebase`."
580-
[repo-path]
581-
(let [sync-args ["-C" (str repo-path) "p4" "sync"]
582-
{:keys [exit err]} (apply shell/sh "git" sync-args)]
583-
(when-not (zero? exit)
584-
(throw (ex-info (str "git p4 sync failed: " (str/trim (or err "")))
585-
{:exit exit :repo-path (str repo-path)}))))
586-
(let [rebase-args ["-C" (str repo-path) "p4" "rebase"]
587-
{:keys [exit err]} (apply shell/sh "git" rebase-args)]
588-
(when-not (zero? exit)
589-
(throw (ex-info (str "git p4 rebase failed: " (str/trim (or err "")))
590-
{:exit exit :repo-path (str repo-path)}))))
591-
(log! (str "git-p4: synced " repo-path))
592-
true)

src/noumenon/mcp.clj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@
6262
:properties repo-path-prop
6363
:required ["repo_path"]}}
6464
{:name "noumenon_update"
65-
:description "Update the knowledge graph with latest changes. Runs import + enrich for changed files. For git-p4 clones, automatically syncs from Perforce first. Fast and cheap (no LLM calls by default). Pass analyze=true to also re-analyze changed files with LLM. Works as a first-time setup too — if no database exists, runs the full pipeline."
65+
:description "Update the knowledge graph with latest changes. Runs import + enrich for changed files. For Perforce-stream clones (made via clj-p4), automatically syncs from Perforce first. Fast and cheap (no LLM calls by default). Pass analyze=true to also re-analyze changed files with LLM. Works as a first-time setup too — if no database exists, runs the full pipeline."
6666
:inputSchema {:type "object"
6767
:properties (merge repo-path-prop
6868
{"analyze" {:type "boolean"

src/noumenon/p4.clj

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
(ns noumenon.p4
2+
"Adapter from Noumenon to clj-p4. The single point of integration with
3+
the pure-Clojure Perforce-to-Git library; everywhere else in the code
4+
talks to this namespace, not to clj-p4 directly.
5+
6+
Connection details (`P4PORT`, `P4USER`, `P4CLIENT`, `P4PASSWD`,
7+
`P4CHARSET`) come from the environment, just like the historical
8+
git-p4 wrapper. Clones are bare; sync derives the stream from the most
9+
recent commit's `git-p4:` trailer."
10+
(:require [clj-p4.api :as api]
11+
[clj-p4.exclude :as p4-exclude]
12+
[clj-p4.spec :as p4-spec]
13+
[clojure.edn :as edn]
14+
[clojure.java.io :as io]
15+
[clojure.string :as str]
16+
[noumenon.util :refer [log!]]))
17+
18+
(def ^:private excludes-resource
19+
(delay (some-> (io/resource "p4-excludes.edn") slurp edn/read-string)))
20+
21+
(defn depot-path?
22+
"True if `s` looks like a Perforce depot path."
23+
[s]
24+
(p4-spec/depot-path? s))
25+
26+
(defn validate-depot-path!
27+
"Throws on invalid depot paths; returns the path otherwise."
28+
[s]
29+
(p4-spec/validate-depot-path! s))
30+
31+
(defn available?
32+
"True if the `p4` CLI is on PATH."
33+
[]
34+
(api/available?))
35+
36+
(defn clone?
37+
"True if `repo-path` is a clj-p4 (or legacy git-p4) clone — has a commit
38+
on `refs/heads/main` whose message contains the `git-p4:` trailer."
39+
[repo-path]
40+
(api/clone? repo-path))
41+
42+
(defn depot->clone-name
43+
"Derive a local clone directory name from a depot path.
44+
//depot/ProjectA/main/... → ProjectA-main
45+
//stream/main/... → main"
46+
[depot-path]
47+
(let [{:depot/keys [depot segments]} (p4-spec/parse-depot-path depot-path)
48+
parts (if (seq segments) segments [depot])]
49+
(-> (str/join "-" parts)
50+
(str/replace #"[^a-zA-Z0-9\-_.]" ""))))
51+
52+
(defn clone-path
53+
"Local clone path for a depot path: `data/repos/<derived-name>`."
54+
[depot-path]
55+
(str "data/repos/" (depot->clone-name depot-path)))
56+
57+
(defn- conn-from-env []
58+
(cond-> {:p4/port (or (System/getenv "P4PORT") "perforce:1666")}
59+
(System/getenv "P4USER") (assoc :p4/user (System/getenv "P4USER"))
60+
(System/getenv "P4CLIENT") (assoc :p4/client (System/getenv "P4CLIENT"))
61+
(System/getenv "P4CHARSET") (assoc :p4/charset (keyword (System/getenv "P4CHARSET")))
62+
(System/getenv "P4PASSWD") (assoc :p4/ticket (System/getenv "P4PASSWD"))))
63+
64+
(defn- progress-fn [op]
65+
(when (= :process-change (:op/kind op))
66+
(log! (str "p4 change " (:op/change op)))))
67+
68+
(defn- compile-excludes [opts]
69+
(let [patterns (p4-exclude/exclude-patterns
70+
(assoc opts :resource @excludes-resource))]
71+
(p4-exclude/compile-patterns patterns)))
72+
73+
(defn clone!
74+
"Clone a Perforce stream into `target-dir` as a bare git repo. Options:
75+
:no-default-excludes? skip the `p4-excludes.edn` resource defaults.
76+
:extra-excludes additional patterns to exclude.
77+
:includes patterns to remove from the union (whitelist).
78+
:max-changes cap on imported changelists."
79+
[depot-path target-dir opts]
80+
(validate-depot-path! depot-path)
81+
(log! (str "clj-p4: cloning " depot-path " into " target-dir))
82+
(api/clone! {:conn (conn-from-env)
83+
:stream depot-path
84+
:target (str target-dir)
85+
:exclude (compile-excludes (or opts {}))
86+
:max-changes (:max-changes opts)
87+
:progress-fn progress-fn})
88+
(log! "clj-p4: clone complete")
89+
(str target-dir))
90+
91+
(defn- last-commit-message [repo-path]
92+
(try
93+
(-> (clj-p4.shell.proc/run-checked!
94+
["git" "-C" (str repo-path) "log" "-1" "--pretty=%B"
95+
"refs/heads/main"])
96+
:stdout-bytes
97+
(String. "UTF-8"))
98+
(catch Exception _ nil)))
99+
100+
(defn- stream-from-trailer
101+
"Extract the stream depot-path from a `git-p4:` commit trailer."
102+
[msg]
103+
(when msg
104+
(when-let [[_ s] (re-find #"depot-paths\s*=\s*\"([^\"]+?)/?\"" msg)]
105+
s)))
106+
107+
(defn sync!
108+
"Bring an existing clj-p4 clone at `repo-path` up to date with Perforce.
109+
Derives the stream from the most recent commit's `git-p4:` trailer."
110+
[repo-path]
111+
(let [stream (-> (last-commit-message repo-path) stream-from-trailer)]
112+
(when-not stream
113+
(throw (ex-info (str "Cannot determine Perforce stream for "
114+
repo-path " — re-clone needed")
115+
{:repo-path (str repo-path)})))
116+
(log! (str "clj-p4: syncing " repo-path " from " stream))
117+
(api/sync! {:conn (conn-from-env)
118+
:stream stream
119+
:target (str repo-path)
120+
:progress-fn progress-fn})
121+
(log! "clj-p4: sync complete")
122+
true))

src/noumenon/repo.clj

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
[datomic.client.api :as d]
77
[noumenon.db :as db]
88
[noumenon.git :as git]
9+
[noumenon.p4 :as p4]
910
[noumenon.util :as util :refer [log!]]))
1011

1112
(defn- clone-or-reuse
@@ -27,13 +28,14 @@
2728
(.getCanonicalPath f))))
2829

2930
(defn- clone-or-reuse-p4
30-
"Clone a Perforce depot path via git-p4, or reuse an existing clone."
31+
"Clone a Perforce stream via clj-p4 as a bare repo, or reuse the existing
32+
clone. Bare-repo presence is detected by the `HEAD` file at the target."
3133
[depot-path opts]
32-
(let [target (git/p4-clone-path depot-path)]
33-
(if (.isDirectory (io/file target ".git"))
34-
(do (log! (str "Using existing git-p4 clone at " target)) target)
35-
(do (log! (str "Cloning P4 depot " depot-path " into " target " ..."))
36-
(git/p4-clone! depot-path target opts)
34+
(let [target (p4/clone-path depot-path)]
35+
(if (.isFile (io/file target "HEAD"))
36+
(do (log! (str "Using existing clj-p4 clone at " target)) target)
37+
(do (log! (str "Cloning P4 stream " depot-path " into " target " ..."))
38+
(p4/clone! depot-path target opts)
3739
target))))
3840

3941
(defn resolve-repo
@@ -42,11 +44,11 @@
4244
Options:
4345
:lookup-uri-fn — (fn [db-dir db-name]) → stored :repo/uri or nil
4446
:db-dir — database storage directory
45-
:p4-opts — options for git-p4 clone (excludes, use-client-spec?, etc.)"
47+
:p4-opts — options for clj-p4 clone (excludes, max-changes, etc.)"
4648
[identifier db-dir {:keys [lookup-uri-fn p4-opts]}]
4749
(cond
48-
;; Perforce depot path — clone via git-p4 and use local path
49-
(git/p4-depot-path? identifier)
50+
;; Perforce depot path — clone via clj-p4 and use local path
51+
(p4/depot-path? identifier)
5052
(let [local (clone-or-reuse-p4 identifier (or p4-opts {}))]
5153
{:repo-path (.getCanonicalPath (io/file local))
5254
:db-name (util/derive-db-name local)})

src/noumenon/repo_manager.clj

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
[datomic.client.api :as d]
77
[noumenon.db :as db]
88
[noumenon.git :as git]
9+
[noumenon.p4 :as p4]
910
[noumenon.sync :as sync]
1011
[noumenon.util :as util :refer [log!]]))
1112

@@ -30,8 +31,8 @@
3031
git@github.com:anthropics/claude-code.git -> anthropics-claude-code
3132
//depot/ProjectA/main/... -> ProjectA-main"
3233
[url]
33-
(let [name (if (git/p4-depot-path? url)
34-
(git/p4-depot->clone-name url)
34+
(let [name (if (p4/depot-path? url)
35+
(p4/depot->clone-name url)
3536
(let [cleaned (-> url (str/replace #"\.git$" "") (str/replace #"/$" ""))
3637
parts (str/split cleaned #"[/:]")
3738
segments (take-last 2 parts)
@@ -64,7 +65,7 @@
6465
Returns {:db-name str :clone-path str :import-result map}."
6566
[meta-conn db-dir {:keys [url name branch p4-opts]}]
6667
(let [db-name (or name (url->db-name url))
67-
p4? (git/p4-depot-path? url)
68+
p4? (p4/depot-path? url)
6869
clone-path (repo-clone-path db-dir db-name)
6970
clone-dir (io/file clone-path)]
7071
;; Prevent path traversal
@@ -77,7 +78,7 @@
7778
;; with read access to the log.
7879
(log! (str "Cloning " url " into " db-name ".git ..."))
7980
(if p4?
80-
(git/p4-clone! url clone-path (or p4-opts {}))
81+
(p4/clone! url clone-path (or p4-opts {}))
8182
(git/clone-bare! url clone-path)))
8283
;; Register in meta database
8384
(d/transact meta-conn
@@ -94,7 +95,7 @@
9495

9596
(defn refresh-repo!
9697
"Fetch latest and run incremental import + enrich for a registered repo.
97-
Detects git-p4 clones and uses p4-sync! instead of git fetch.
98+
Detects clj-p4 clones and uses `noumenon.p4/sync!` instead of git fetch.
9899
Returns summary map."
99100
[db-dir db-name]
100101
(let [clone-path (repo-clone-path db-dir db-name)
@@ -105,8 +106,8 @@
105106
(throw (ex-info (str "Clone not found for " db-name)
106107
{:db-name db-name :clone-path clone-path})))
107108
(log! (str "Fetching " db-name " ..."))
108-
(if (git/p4-clone? clone-path)
109-
(git/p4-sync! clone-path)
109+
(if (p4/clone? clone-path)
110+
(p4/sync! clone-path)
110111
(git/fetch! clone-path))
111112
(let [repo-uri (or (ffirst (d/q '[:find ?uri :where [_ :repo/uri ?uri]]
112113
(d/db conn)))

0 commit comments

Comments
 (0)