summaryrefslogtreecommitdiff
path: root/Omni/Agent/Git.hs
blob: 4c06cf6e85aa34caecc7f70eebd2d2db7064586d (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE NoImplicitPrelude #-}

-- | Git operations for the agent.
--
-- : out omni-agent-git
-- : dep temporary
module Omni.Agent.Git
  ( checkout,
    syncWithLive,
    commit,
    createBranch,
    getCurrentBranch,
    branchExists,
    isMerged,
    listBranches,
    showFile,
    getRepoRoot,
    runGit,
    main,
    test,
  )
where

import Alpha
import qualified Data.Text as Text
import qualified Omni.Log as Log
import Omni.Test ((@=?))
import qualified Omni.Test as Test
import qualified System.Directory as Directory
import qualified System.Exit as Exit
import qualified System.IO.Temp as Temp
import qualified System.Process as Process

main :: IO ()
main = Test.run test

test :: Test.Tree
test =
  Test.group
    "Omni.Agent.Git"
    [ Test.unit "checkout works" <| do
        Temp.withSystemTempDirectory "omni-agent-git-test" <| \tmpDir -> do
          let repo = tmpDir <> "/repo"
          Directory.createDirectory repo
          -- init repo
          git repo ["init"]
          git repo ["branch", "-m", "master"]
          git repo ["config", "user.email", "you@example.com"]
          git repo ["config", "user.name", "Your Name"]

          -- commit A
          writeFile (repo <> "/a.txt") "A"
          git repo ["add", "a.txt"]
          git repo ["commit", "-m", "A"]
          shaA <- getSha repo "HEAD"

          -- create branch dev
          git repo ["checkout", "-b", "dev"]

          -- commit B
          writeFile (repo <> "/b.txt") "B"
          git repo ["add", "b.txt"]
          git repo ["commit", "-m", "B"]
          shaB <- getSha repo "HEAD"

          -- switch back to master
          git repo ["checkout", "master"]

          -- Test 1: checkout dev
          checkout repo "dev"
          current <- getSha repo "HEAD"
          shaB @=? current

          -- Test 2: checkout master
          checkout repo "master"
          current' <- getSha repo "HEAD"
          shaA @=? current'

          -- Test 3: dirty state
          writeFile (repo <> "/a.txt") "DIRTY"
          checkout repo "dev"
          current'' <- getSha repo "HEAD"
          shaB @=? current''
          -- Verify dirty file is gone/overwritten (b.txt should exist, a.txt should be A from master? No, a.txt is in A and B)
          -- Wait, in dev, a.txt is "A".
          content <- readFile (repo <> "/a.txt")
          "A" @=? content

          -- Test 4: untracked file
          writeFile (repo <> "/untracked.txt") "DELETE ME"
          checkout repo "master"
          exists <- Directory.doesFileExist (repo <> "/untracked.txt")
          False @=? exists
    ]

getSha :: FilePath -> String -> IO String
getSha dir ref = do
  let cmd = (Process.proc "git" ["rev-parse", ref]) {Process.cwd = Just dir}
  (code, out, _) <- Process.readCreateProcessWithExitCode cmd ""
  case code of
    Exit.ExitSuccess -> pure <| strip out
    _ -> panic "getSha failed"

-- | Checkout a specific ref (SHA, branch, tag) in the given repository path.
-- This function ensures the repository is in the correct state by:
-- 1. Fetching all updates
-- 2. Checking out the ref (forcing overwrites of local changes)
-- 3. Resetting hard to the ref (to ensure clean state)
-- 4. Cleaning untracked files
-- 5. Updating submodules
checkout :: FilePath -> Text -> IO ()
checkout repoPath ref = do
  let r = Text.unpack ref

  Log.info ["git", "checkout", ref, "in", Text.pack repoPath]

  -- Fetch all refs to ensure we have the target
  git repoPath ["fetch", "--all", "--tags"]

  -- Checkout the ref, discarding local changes
  git repoPath ["checkout", "--force", r]

  -- Reset hard to ensure we are exactly at the target state
  git repoPath ["reset", "--hard", r]

  -- Remove untracked files and directories
  git repoPath ["clean", "-fdx"]

  -- Update submodules
  git repoPath ["submodule", "update", "--init", "--recursive"]

  Log.good ["git", "checkout", "complete"]
  Log.br

-- | Run a git command in the given directory.
git :: FilePath -> [String] -> IO ()
git dir args = do
  let cmd = (Process.proc "git" args) {Process.cwd = Just dir}
  (exitCode, out, err) <- Process.readCreateProcessWithExitCode cmd ""
  case exitCode of
    Exit.ExitSuccess -> pure ()
    Exit.ExitFailure code -> do
      Log.fail ["git command failed", Text.pack (show args), "code: " <> show code]
      Log.info [Text.pack out]
      Log.info [Text.pack err]
      Log.br
      panic <| "git command failed: git " <> show args

syncWithLive :: FilePath -> IO ()
syncWithLive repo = do
  Log.info ["git", "syncing with live"]
  -- git repo ["fetch", "origin", "live"] -- Optional

  -- Try sync (branchless sync), if fail, panic
  -- This replaces manual rebase and handles stack movement
  let cmd = (Process.proc "git" ["sync"]) {Process.cwd = Just repo}
  (code, out, err) <- Process.readCreateProcessWithExitCode cmd ""
  case code of
    Exit.ExitSuccess -> pure ()
    Exit.ExitFailure _ -> do
      Log.warn ["git sync failed", Text.pack err]
      Log.info [Text.pack out]
      panic "Sync with live failed (git sync)"

commit :: FilePath -> Text -> IO ()
commit repo msg = do
  Log.info ["git", "commit", msg]
  git repo ["add", "."]

  -- Check for changes before committing to avoid error
  let checkCmd = (Process.proc "git" ["diff", "--cached", "--quiet"]) {Process.cwd = Just repo}
  (code, _, _) <- Process.readCreateProcessWithExitCode checkCmd ""

  case code of
    Exit.ExitSuccess -> Log.warn ["git", "nothing to commit", "skipping"]
    Exit.ExitFailure 1 -> git repo ["commit", "-m", Text.unpack msg]
    Exit.ExitFailure c -> panic <| "git diff failed with code " <> show c

createBranch :: FilePath -> Text -> IO ()
createBranch repo branch = do
  Log.info ["git", "create branch", branch]
  git repo ["checkout", "-b", Text.unpack branch]

getCurrentBranch :: FilePath -> IO Text
getCurrentBranch repo = do
  let cmd = (Process.proc "git" ["branch", "--show-current"]) {Process.cwd = Just repo}
  (code, out, _) <- Process.readCreateProcessWithExitCode cmd ""
  case code of
    Exit.ExitSuccess -> pure <| Text.strip (Text.pack out)
    _ -> panic "git branch failed"

branchExists :: FilePath -> Text -> IO Bool
branchExists repo branch = do
  let cmd = (Process.proc "git" ["show-ref", "--verify", "refs/heads/" <> Text.unpack branch]) {Process.cwd = Just repo}
  (code, _, _) <- Process.readCreateProcessWithExitCode cmd ""
  pure (code == Exit.ExitSuccess)

isMerged :: FilePath -> Text -> Text -> IO Bool
isMerged repo branch target = do
  -- Check if 'branch' is merged into 'target'
  -- git merge-base --is-ancestor <branch> <target>
  let cmd = (Process.proc "git" ["merge-base", "--is-ancestor", Text.unpack branch, Text.unpack target]) {Process.cwd = Just repo}
  (code, _, _) <- Process.readCreateProcessWithExitCode cmd ""
  pure (code == Exit.ExitSuccess)

listBranches :: FilePath -> Text -> IO [Text]
listBranches repo pat = do
  let cmd = (Process.proc "git" ["branch", "--list", Text.unpack pat, "--format=%(refname:short)"]) {Process.cwd = Just repo}
  (code, out, _) <- Process.readCreateProcessWithExitCode cmd ""
  case code of
    Exit.ExitSuccess -> pure <| filter (not <. Text.null) (Text.lines (Text.pack out))
    _ -> panic "git branch list failed"

showFile :: FilePath -> Text -> FilePath -> IO (Maybe Text)
showFile repo branch path = do
  let cmd = (Process.proc "git" ["show", Text.unpack branch <> ":" <> path]) {Process.cwd = Just repo}
  (code, out, _) <- Process.readCreateProcessWithExitCode cmd ""
  case code of
    Exit.ExitSuccess -> pure <| Just (Text.pack out)
    _ -> pure Nothing

getRepoRoot :: FilePath -> IO FilePath
getRepoRoot dir = do
  let cmd = (Process.proc "git" ["rev-parse", "--show-toplevel"]) {Process.cwd = Just dir}
  (code, out, _) <- Process.readCreateProcessWithExitCode cmd ""
  case code of
    Exit.ExitSuccess -> pure <| strip out
    _ -> panic "git rev-parse failed"

runGit :: FilePath -> [String] -> IO ()
runGit = git