Line data Source code
1 : /*!
2 : * \file esys/repo/gitcmdline/git_cmdline.cpp
3 : * \brief System ``git`` CLI backend (Phase-1 esysrepo surface)
4 : *
5 : * \cond
6 : * __legal_b__
7 : *
8 : * Copyright (c) 2020-2026 Michel Gillet
9 : * Distributed under the MIT License.
10 : * (See accompanying file LICENSE.txt or
11 : * copy at https://opensource.org/licenses/MIT)
12 : *
13 : * __legal_e__
14 : * \endcond
15 : *
16 : */
17 :
18 : #include "esys/repo/esysrepo_prec.h"
19 : #include "esys/repo/gitcmdline/git.h"
20 :
21 : #include "esys/repo/git/aheadbehind.h"
22 : #include "esys/repo/git/branch.h"
23 : #include "esys/repo/git/commit.h"
24 : #include "esys/repo/git/commithash.h"
25 : #include "esys/repo/git/remote.h"
26 : #include "esys/repo/git/repostatus.h"
27 : #include "esys/repo/git/resettype.h"
28 : #include "esys/repo/git/status.h"
29 : #include "esys/repo/git/statussubtype.h"
30 : #include "esys/repo/git/statustype.h"
31 :
32 : #include <boost/filesystem.hpp>
33 : #include <boost/process/search_path.hpp>
34 :
35 : #include <chrono>
36 : #include <cstdio>
37 : #include <cstdlib>
38 : #include <ctime>
39 : #include <map>
40 : #include <memory>
41 : #include <sstream>
42 : #include <string>
43 : #include <vector>
44 :
45 : #ifdef _WIN32
46 : #define ESYSREPO_POPEN _popen
47 : #define ESYSREPO_PCLOSE _pclose
48 : #else
49 : #define ESYSREPO_POPEN popen
50 : #define ESYSREPO_PCLOSE pclose
51 : #endif
52 :
53 : namespace esys::repo::gitcmdline
54 : {
55 :
56 : namespace
57 : {
58 :
59 1708 : std::string shell_quote(const std::string &arg)
60 : {
61 : #ifdef _WIN32
62 : // cmd.exe: double % (env expansion) and quote when spaces or shell metacharacters
63 : // appear (notably '|' in git for-each-ref --format).
64 : std::string escaped;
65 : escaped.reserve(arg.size() * 2);
66 : for (char c : arg)
67 : {
68 : if (c == '%') escaped += '%';
69 : escaped += c;
70 : }
71 : const bool need_quote = escaped.find_first_of(" \t\"|&<>^!") != std::string::npos;
72 : if (!need_quote) return escaped;
73 : std::string out = "\"";
74 : for (char c : escaped)
75 : {
76 : // cmd embedded quote is ""
77 : if (c == '"') out += '"';
78 : out += c;
79 : }
80 : out += '"';
81 : return out;
82 : #else
83 1708 : if (arg.find_first_of(" \t\"'\\") == std::string::npos) return arg;
84 0 : std::string out = "\"";
85 0 : for (char c : arg)
86 : {
87 0 : if (c == '"') out += '\\';
88 0 : out += c;
89 : }
90 0 : out += '"';
91 0 : return out;
92 : #endif
93 1708 : }
94 :
95 41 : std::string trim(std::string s)
96 : {
97 71 : while (!s.empty() && (s.back() == '\n' || s.back() == '\r' || s.back() == ' ' || s.back() == '\t')) s.pop_back();
98 : std::size_t i = 0;
99 43 : while (i < s.size() && (s[i] == ' ' || s[i] == '\t' || s[i] == '\n' || s[i] == '\r')) ++i;
100 41 : return s.substr(i);
101 : }
102 :
103 2 : std::string strip_rev_prefix(std::string rev)
104 : {
105 2 : const std::string heads = "refs/heads/";
106 2 : const std::string tags = "refs/tags/";
107 2 : if (rev.compare(0, heads.size(), heads) == 0) return rev.substr(heads.size());
108 2 : if (rev.compare(0, tags.size(), tags) == 0) return rev.substr(tags.size());
109 4 : return rev;
110 2 : }
111 :
112 7 : std::vector<std::string> split_lines(const std::string &text)
113 : {
114 7 : std::vector<std::string> lines;
115 7 : std::string cur;
116 745 : for (char c : text)
117 : {
118 738 : if (c == '\n')
119 : {
120 14 : if (!cur.empty() && cur.back() == '\r') cur.pop_back();
121 14 : lines.push_back(cur);
122 14 : cur.clear();
123 : }
124 : else
125 1462 : cur += c;
126 : }
127 7 : if (!cur.empty())
128 : {
129 0 : if (cur.back() == '\r') cur.pop_back();
130 0 : lines.push_back(cur);
131 : }
132 7 : return lines;
133 7 : }
134 :
135 1 : git::StatusSubType porcelain_to_subtype(char code)
136 : {
137 1 : switch (code)
138 : {
139 : case 'A': return git::StatusSubType::NEW;
140 : case 'M': return git::StatusSubType::MODIFIED;
141 : case 'D': return git::StatusSubType::DELETED;
142 : case 'R': return git::StatusSubType::RENAMED;
143 : case 'C': return git::StatusSubType::NEW;
144 : case 'T': return git::StatusSubType::TYPECHANGE;
145 : case '?': return git::StatusSubType::NEW;
146 : default: return git::StatusSubType::NOT_SET;
147 : }
148 : }
149 :
150 1 : git::DiffDeltaType porcelain_to_delta_type(char code, bool worktree)
151 : {
152 1 : switch (code)
153 : {
154 : case 'A': return git::DiffDeltaType::ADDED;
155 1 : case 'M': return git::DiffDeltaType::MODIFIED;
156 0 : case 'D': return git::DiffDeltaType::DELETED;
157 0 : case 'R': return git::DiffDeltaType::RENAMED;
158 0 : case 'C': return git::DiffDeltaType::COPIED;
159 0 : case 'T': return git::DiffDeltaType::TYPECHANGE;
160 0 : case '?': return worktree ? git::DiffDeltaType::UNTRACKED : git::DiffDeltaType::ADDED;
161 0 : case '!': return git::DiffDeltaType::IGNORED;
162 0 : case 'U': return git::DiffDeltaType::CONFLICTED;
163 0 : default: return git::DiffDeltaType::NOT_SET;
164 : }
165 : }
166 :
167 2 : void fill_status_paths(git::Status &status, const std::string &old_path, const std::string &new_path,
168 : git::DiffDeltaType delta_type)
169 : {
170 2 : auto &delta = status.get_diff_delta();
171 2 : delta.set_type(delta_type);
172 2 : delta.get_old_file().set_path(old_path);
173 4 : delta.get_new_file().set_path(new_path.empty() ? old_path : new_path);
174 2 : delta.set_file_count(old_path == new_path || new_path.empty() ? 1 : 2);
175 2 : }
176 :
177 2 : bool parse_porcelain_paths(const std::string &rest, std::string &old_path, std::string &new_path)
178 : {
179 : // XY<path> or XY <ORIG> -> <PATH> (rename/copy). Optional leading space after XY.
180 2 : std::string path_part = rest;
181 2 : if (!path_part.empty() && path_part[0] == ' ') path_part.erase(0, 1);
182 :
183 2 : const std::string arrow = " -> ";
184 2 : const auto arrow_pos = path_part.find(arrow);
185 2 : if (arrow_pos != std::string::npos)
186 : {
187 0 : old_path = path_part.substr(0, arrow_pos);
188 0 : new_path = path_part.substr(arrow_pos + arrow.size());
189 0 : return !old_path.empty() && !new_path.empty();
190 : }
191 :
192 2 : old_path = path_part;
193 2 : new_path = path_part;
194 2 : return !old_path.empty();
195 2 : }
196 :
197 2 : void add_porcelain_status(git::RepoStatus &repo_status, git::StatusType type, git::StatusSubType sub_type,
198 : git::DiffDeltaType delta_type, const std::string &old_path, const std::string &new_path)
199 : {
200 2 : auto status = std::make_shared<git::Status>();
201 2 : status->set_type(type);
202 2 : status->set_sub_type(sub_type);
203 : // RepoStatus::add keys the file map by old_file path — always set it.
204 2 : fill_status_paths(*status, old_path, new_path, delta_type);
205 6 : repo_status.add(status);
206 2 : }
207 :
208 : } // namespace
209 :
210 : bool Git::s_detect_ssh_agent_done = false;
211 : bool Git::s_ssh_agent_running = false;
212 :
213 0 : std::shared_ptr<GitBase> Git::new_ptr()
214 : {
215 0 : return std::make_shared<Git>();
216 : }
217 :
218 191 : Git::Git()
219 191 : : GitBase()
220 : {
221 191 : }
222 :
223 191 : Git::~Git()
224 : {
225 191 : if (m_open) close();
226 191 : }
227 :
228 567 : std::string Git::find_git_executable()
229 : {
230 567 : if (const char *env = std::getenv("ESYSREPO_GIT_EXE"))
231 : {
232 0 : if (env[0] != '\0' && boost::filesystem::exists(env)) return std::string(env);
233 : }
234 567 : if (const char *env = std::getenv("GIT_EXECUTABLE"))
235 : {
236 0 : if (env[0] != '\0' && boost::filesystem::exists(env)) return std::string(env);
237 : }
238 1134 : boost::filesystem::path found = boost::process::search_path("git");
239 : // Prefer the bare name so std::system/cmd.exe does not break on spaces in
240 : // "C:\Program Files\Git\..." (PATH already contains the directory).
241 567 : if (!found.empty()) return "git";
242 567 : return {};
243 567 : }
244 :
245 0 : Result Git::not_implemented() const
246 : {
247 0 : return ESYSREPO_RESULT(ResultCode::NOT_IMPLEMENTED);
248 : }
249 :
250 279 : std::string Git::build_git_command(const std::vector<std::string> &args, const std::string &cwd) const
251 : {
252 279 : const std::string git = find_git_executable();
253 279 : std::ostringstream cmd;
254 558 : cmd << shell_quote(git);
255 374 : if (!cwd.empty()) cmd << " -C " << shell_quote(cwd);
256 1613 : for (const auto &arg : args) cmd << ' ' << shell_quote(arg);
257 279 : return cmd.str();
258 279 : }
259 :
260 4 : Result Git::run_git(const std::vector<std::string> &args, const std::string &cwd)
261 : {
262 4 : const std::string git = find_git_executable();
263 4 : if (git.empty()) return ESYSREPO_RESULT(ResultCode::GIT_GENERIC_ERROR, "git executable not found");
264 :
265 4 : const std::string cmd = build_git_command(args, cwd);
266 8 : debug(1, std::string("[gitcmdline] ") + cmd);
267 4 : cmd_start();
268 4 : const int rc = std::system(cmd.c_str());
269 4 : cmd_end();
270 4 : if (rc != 0) return ESYSREPO_RESULT(ResultCode::RAW_INT_ERROR, rc, cmd);
271 4 : return ESYSREPO_RESULT(ResultCode::OK);
272 4 : }
273 :
274 877 : void Git::feed_progress_stderr(std::string &pending, const char *data, std::size_t len)
275 : {
276 877 : pending.append(data, len);
277 877 : std::size_t start = 0;
278 793519 : for (std::size_t i = 0; i < pending.size(); ++i)
279 : {
280 792642 : const char c = pending[i];
281 792642 : if (c != '\r' && c != '\n') continue;
282 36518 : if (i > start) handle_sideband_progress(pending.substr(start, i - start));
283 18259 : start = i + 1;
284 : }
285 877 : if (start > 0) pending.erase(0, start);
286 877 : }
287 :
288 239 : Result Git::run_git_progress(const std::vector<std::string> &args, const std::string &cwd)
289 : {
290 239 : const std::string git = find_git_executable();
291 239 : if (git.empty()) return ESYSREPO_RESULT(ResultCode::GIT_GENERIC_ERROR, "git executable not found");
292 :
293 : // Merge stderr into the pipe so ``--progress`` lines (CR-updated) are readable.
294 239 : std::string cmd = build_git_command(args, cwd);
295 239 : cmd += " 2>&1";
296 :
297 478 : debug(1, std::string("[gitcmdline] ") + cmd);
298 239 : cmd_start();
299 239 : FILE *pipe = ESYSREPO_POPEN(cmd.c_str(), "r");
300 239 : if (pipe == nullptr)
301 : {
302 0 : cmd_end();
303 0 : return ESYSREPO_RESULT(ResultCode::GIT_GENERIC_ERROR, "failed to start git");
304 : }
305 :
306 239 : std::string pending;
307 925 : char buf[1024];
308 925 : while (true)
309 : {
310 925 : const std::size_t n = std::fread(buf, 1, sizeof(buf), pipe);
311 925 : if (n > 0) feed_progress_stderr(pending, buf, n);
312 925 : if (n < sizeof(buf))
313 : {
314 239 : if (std::feof(pipe)) break;
315 0 : if (std::ferror(pipe)) break;
316 : }
317 : }
318 239 : if (!pending.empty()) handle_sideband_progress(pending);
319 :
320 239 : const int rc = ESYSREPO_PCLOSE(pipe);
321 239 : cmd_end();
322 :
323 239 : git::Progress done;
324 239 : done.set_done(true);
325 239 : done.set_percentage(git::Progress::MAX_PERCENTAGE);
326 239 : handle_transfer_progress(done);
327 :
328 239 : if (rc != 0) return ESYSREPO_RESULT(ResultCode::RAW_INT_ERROR, rc, cmd);
329 209 : return ESYSREPO_RESULT(ResultCode::OK);
330 478 : }
331 :
332 36 : Result Git::run_git_out(const std::vector<std::string> &args, const std::string &cwd, std::string &output)
333 : {
334 36 : output.clear();
335 36 : const std::string git = find_git_executable();
336 36 : if (git.empty()) return ESYSREPO_RESULT(ResultCode::GIT_GENERIC_ERROR, "git executable not found");
337 :
338 : // Capture stdout; discard stderr so porcelain/status noise does not pollute parsing.
339 36 : std::string cmd = build_git_command(args, cwd);
340 : #ifdef _WIN32
341 : cmd += " 2>NUL";
342 : #else
343 36 : cmd += " 2>/dev/null";
344 : #endif
345 :
346 72 : debug(1, std::string("[gitcmdline] ") + cmd);
347 36 : cmd_start();
348 36 : FILE *pipe = ESYSREPO_POPEN(cmd.c_str(), "r");
349 36 : if (pipe == nullptr)
350 : {
351 0 : cmd_end();
352 0 : return ESYSREPO_RESULT(ResultCode::GIT_GENERIC_ERROR, "failed to start git");
353 : }
354 : char buf[4096];
355 78 : while (std::fgets(buf, sizeof(buf), pipe) != nullptr) output += buf;
356 36 : const int rc = ESYSREPO_PCLOSE(pipe);
357 36 : cmd_end();
358 36 : if (rc != 0) return ESYSREPO_RESULT(ResultCode::RAW_INT_ERROR, rc, cmd);
359 36 : return ESYSREPO_RESULT(ResultCode::OK);
360 36 : }
361 :
362 277 : Result Git::open(const std::string &folder)
363 : {
364 277 : if (!is_repo(folder)) return ESYSREPO_RESULT(ResultCode::GIT_GENERIC_ERROR, "not a git repository");
365 277 : m_folder = folder;
366 277 : m_open = true;
367 277 : open_time();
368 277 : return ESYSREPO_RESULT(ResultCode::OK);
369 : }
370 :
371 757 : bool Git::is_open()
372 : {
373 757 : return m_open;
374 : }
375 :
376 277 : Result Git::close()
377 : {
378 277 : m_open = false;
379 277 : m_folder.clear();
380 277 : close_time();
381 277 : return ESYSREPO_RESULT(ResultCode::OK);
382 : }
383 :
384 0 : void Git::close_on_error()
385 : {
386 0 : if (m_open) close();
387 0 : }
388 :
389 184 : Result Git::clone(const std::string &url, const std::string &path, const std::string &rev,
390 : const git::CloneOptions &options)
391 : {
392 184 : if (url.empty() || path.empty()) return ESYSREPO_RESULT(ResultCode::GIT_GENERIC_ERROR, "empty url or path");
393 :
394 552 : std::vector<std::string> args = {"clone", "--progress"};
395 : // Default CloneOptions: ignore rev (legacy remote-default tip only).
396 184 : if (options.checkout_rev && !rev.empty())
397 : {
398 125 : args.emplace_back("--branch");
399 125 : args.push_back(rev);
400 : }
401 184 : if (options.checkout_rev && options.single_branch) args.emplace_back("--single-branch");
402 184 : args.push_back(url);
403 184 : args.push_back(path);
404 :
405 184 : auto result = run_git_progress(args);
406 214 : if (result.error()) return ESYSREPO_RESULT(result);
407 :
408 154 : return open(path);
409 184 : }
410 :
411 55 : Result Git::fetch(const std::string &remote)
412 : {
413 55 : if (!m_open) return ESYSREPO_RESULT(ResultCode::GIT_GENERIC_ERROR, "repository not open");
414 :
415 159 : std::vector<std::string> args = {"fetch", "--progress"};
416 53 : if (!remote.empty()) args.push_back(remote);
417 53 : return run_git_progress(args, m_folder);
418 53 : }
419 :
420 2 : Result Git::fetch_refspec(const std::string &refspec, const std::string &remote)
421 : {
422 2 : if (!m_open) return ESYSREPO_RESULT(ResultCode::GIT_GENERIC_ERROR, "repository not open");
423 2 : if (refspec.empty()) return fetch(remote);
424 :
425 6 : std::vector<std::string> args = {"fetch", "--progress"};
426 2 : args.push_back(remote.empty() ? "origin" : remote);
427 2 : args.push_back(refspec);
428 2 : return run_git_progress(args, m_folder);
429 2 : }
430 :
431 0 : Result Git::update_submodules(const std::string &path, bool recursive)
432 : {
433 0 : if (path.empty()) return ESYSREPO_RESULT(ResultCode::GIT_GENERIC_ERROR, "empty path");
434 0 : std::vector<std::string> args = {"submodule", "update", "--init"};
435 0 : if (recursive) args.push_back("--recursive");
436 0 : return run_git(args, path);
437 0 : }
438 :
439 0 : Result Git::init_bare(const std::string &folder_path)
440 : {
441 0 : if (folder_path.empty()) return ESYSREPO_RESULT(ResultCode::GIT_GENERIC_ERROR, "empty path");
442 0 : auto result = run_git({"init", "--bare", folder_path});
443 0 : if (result.error()) return ESYSREPO_RESULT(result);
444 0 : return open(folder_path);
445 0 : }
446 :
447 0 : Result Git::init(const std::string &folder_path)
448 : {
449 0 : if (folder_path.empty()) return ESYSREPO_RESULT(ResultCode::GIT_GENERIC_ERROR, "empty path");
450 0 : auto result = run_git({"init", folder_path});
451 0 : if (result.error()) return ESYSREPO_RESULT(result);
452 0 : return open(folder_path);
453 0 : }
454 :
455 1 : Result Git::get_remotes(std::vector<git::Remote> &remotes)
456 : {
457 1 : if (!m_open) return ESYSREPO_RESULT(ResultCode::GIT_GENERIC_ERROR, "repository not open");
458 1 : remotes.clear();
459 :
460 1 : std::string out;
461 4 : auto result = run_git_out({"remote", "-v"}, m_folder, out);
462 1 : if (result.error()) return ESYSREPO_RESULT(result);
463 :
464 1 : std::map<std::string, std::string> name_to_url;
465 3 : for (const auto &line : split_lines(out))
466 : {
467 : // name <tab/sp> url <sp>(fetch|push)
468 2 : const auto first_sp = line.find_first_of(" \t");
469 2 : if (first_sp == std::string::npos) continue;
470 2 : const std::string name = line.substr(0, first_sp);
471 2 : std::string rest = trim(line.substr(first_sp + 1));
472 2 : const bool is_fetch = rest.size() >= 8 && rest.compare(rest.size() - 8, 8, " (fetch)") == 0;
473 2 : const bool is_push = rest.size() >= 7 && rest.compare(rest.size() - 7, 7, " (push)") == 0;
474 2 : if (is_fetch)
475 1 : rest = trim(rest.substr(0, rest.size() - 8));
476 1 : else if (is_push)
477 1 : rest = trim(rest.substr(0, rest.size() - 7));
478 : else
479 0 : continue;
480 :
481 3 : if (is_fetch || name_to_url.find(name) == name_to_url.end()) name_to_url[name] = rest;
482 3 : }
483 :
484 2 : for (const auto &kv : name_to_url)
485 : {
486 1 : git::Remote remote;
487 1 : remote.set_name(kv.first);
488 1 : remote.set_url(kv.second);
489 1 : remotes.push_back(remote);
490 1 : }
491 1 : return ESYSREPO_RESULT(ResultCode::OK);
492 2 : }
493 :
494 1 : Result Git::get_head_branch_remote(git::Branch &branch, git::Remote &remote)
495 : {
496 1 : if (!m_open) return ESYSREPO_RESULT(ResultCode::GIT_GENERIC_ERROR, "repository not open");
497 :
498 1 : std::string ref_name;
499 5 : auto result = run_git_out({"symbolic-ref", "-q", "HEAD"}, m_folder, ref_name);
500 1 : const bool detached = result.error();
501 1 : ref_name = trim(ref_name);
502 :
503 1 : std::string short_name;
504 4 : result = run_git_out({"rev-parse", "--abbrev-ref", "HEAD"}, m_folder, short_name);
505 1 : if (result.error()) return ESYSREPO_RESULT(result);
506 1 : short_name = trim(short_name);
507 :
508 1 : branch.set_is_head(true);
509 1 : branch.set_type(git::BranchType::LOCAL);
510 1 : branch.set_detached(detached);
511 1 : branch.set_name(short_name == "HEAD" ? std::string() : short_name);
512 1 : if (!detached && !ref_name.empty())
513 1 : branch.set_ref_name(ref_name);
514 0 : else if (!branch.get_name().empty())
515 0 : branch.set_ref_name("refs/heads/" + branch.get_name());
516 :
517 1 : if (detached || branch.get_name().empty())
518 0 : return ESYSREPO_RESULT(ResultCode::GIT_GENERIC_ERROR, "HEAD is detached or has no branch");
519 :
520 1 : std::string remote_name;
521 1 : std::string merge_ref;
522 1 : std::string cfg_out;
523 4 : if (run_git_out({"config", "--get", "branch." + branch.get_name() + ".remote"}, m_folder, cfg_out).ok())
524 1 : remote_name = trim(cfg_out);
525 4 : if (run_git_out({"config", "--get", "branch." + branch.get_name() + ".merge"}, m_folder, cfg_out).ok())
526 1 : merge_ref = trim(cfg_out);
527 :
528 1 : if (remote_name.empty() || merge_ref.empty())
529 0 : return ESYSREPO_RESULT(ResultCode::GIT_GENERIC_ERROR, "branch has no upstream remote");
530 :
531 1 : branch.set_remote_name(remote_name);
532 1 : remote.set_name(remote_name);
533 :
534 1 : std::string remote_url;
535 4 : result = run_git_out({"remote", "get-url", remote_name}, m_folder, remote_url);
536 1 : if (result.error()) return ESYSREPO_RESULT(result);
537 2 : remote.set_url(trim(remote_url));
538 :
539 1 : const std::string merge_heads = "refs/heads/";
540 1 : std::string short_merge = merge_ref;
541 1 : if (merge_ref.compare(0, merge_heads.size(), merge_heads) == 0)
542 1 : short_merge = merge_ref.substr(merge_heads.size());
543 :
544 2 : branch.set_remote_branch("refs/remotes/" + remote_name + "/" + short_merge);
545 2 : branch.set_remote_branch_name(remote_name + "/" + short_merge);
546 1 : return ESYSREPO_RESULT(ResultCode::OK);
547 5 : }
548 :
549 0 : Result Git::add_remote(const std::string &name, const std::string &url)
550 : {
551 0 : if (!m_open) return ESYSREPO_RESULT(ResultCode::GIT_GENERIC_ERROR, "repository not open");
552 0 : if (name.empty() || url.empty()) return ESYSREPO_RESULT(ResultCode::GIT_GENERIC_ERROR, "empty name or url");
553 0 : return run_git({"remote", "add", name, url}, m_folder);
554 : }
555 :
556 4 : Result Git::get_branches(git::Branches &branches, git::BranchType branch_type)
557 : {
558 4 : if (!m_open) return ESYSREPO_RESULT(ResultCode::GIT_GENERIC_ERROR, "repository not open");
559 4 : branches.clear();
560 :
561 4 : if (branch_type == git::BranchType::LOCAL || branch_type == git::BranchType::ALL)
562 : {
563 4 : std::string head_name;
564 20 : auto result = run_git_out({"rev-parse", "--abbrev-ref", "HEAD"}, m_folder, head_name);
565 4 : if (result.error()) return ESYSREPO_RESULT(result);
566 4 : head_name = trim(head_name);
567 4 : if (head_name == "HEAD") head_name.clear(); // detached
568 :
569 4 : std::string out;
570 12 : result = run_git_out({"show-ref", "--heads"}, m_folder, out);
571 : // Empty repo / no heads: not an error for listing.
572 4 : if (result.ok())
573 : {
574 8 : for (const auto &line : split_lines(out))
575 : {
576 : // <hash> <sp> refs/heads/<name>
577 4 : const auto sp = line.find(' ');
578 4 : if (sp == std::string::npos) continue;
579 4 : const std::string ref = trim(line.substr(sp + 1));
580 4 : const std::string heads = "refs/heads/";
581 4 : if (ref.compare(0, heads.size(), heads) != 0) continue;
582 4 : const std::string name = ref.substr(heads.size());
583 :
584 4 : auto branch = std::make_shared<git::Branch>();
585 4 : branch->set_ref_name(ref);
586 4 : branch->set_name(name);
587 4 : branch->set_type(git::BranchType::LOCAL);
588 4 : branch->set_is_head(!head_name.empty() && name == head_name);
589 :
590 4 : std::string remote;
591 4 : std::string merge;
592 4 : std::string cfg_out;
593 16 : if (run_git_out({"config", "--get", "branch." + name + ".remote"}, m_folder, cfg_out).ok())
594 4 : remote = trim(cfg_out);
595 16 : if (run_git_out({"config", "--get", "branch." + name + ".merge"}, m_folder, cfg_out).ok())
596 4 : merge = trim(cfg_out);
597 4 : if (!remote.empty() && !merge.empty())
598 : {
599 4 : branch->set_remote_name(remote);
600 : // Prefer refs/remotes/<remote>/<branch> for merge_analysis tips.
601 4 : const std::string merge_heads = "refs/heads/";
602 4 : std::string short_merge = merge;
603 4 : if (merge.compare(0, merge_heads.size(), merge_heads) == 0)
604 4 : short_merge = merge.substr(merge_heads.size());
605 8 : branch->set_remote_branch("refs/remotes/" + remote + "/" + short_merge);
606 4 : }
607 12 : branches.add(branch);
608 12 : }
609 : }
610 4 : }
611 :
612 4 : if (branch_type == git::BranchType::REMOTE || branch_type == git::BranchType::ALL)
613 : {
614 0 : std::string out;
615 0 : auto result = run_git_out({"show-ref"}, m_folder, out);
616 0 : if (result.ok())
617 : {
618 0 : for (const auto &line : split_lines(out))
619 : {
620 0 : const auto sp = line.find(' ');
621 0 : if (sp == std::string::npos) continue;
622 0 : const std::string ref = trim(line.substr(sp + 1));
623 0 : const std::string remotes = "refs/remotes/";
624 0 : if (ref.compare(0, remotes.size(), remotes) != 0) continue;
625 0 : if (ref.size() >= 5 && ref.compare(ref.size() - 5, 5, "/HEAD") == 0) continue;
626 :
627 0 : auto branch = std::make_shared<git::Branch>();
628 0 : branch->set_ref_name(ref);
629 0 : branch->set_name(ref.substr(remotes.size()));
630 0 : branch->set_type(git::BranchType::REMOTE);
631 0 : branches.add(branch);
632 0 : }
633 : }
634 0 : }
635 :
636 4 : return ESYSREPO_RESULT(ResultCode::OK);
637 : }
638 :
639 1 : Result Git::checkout(const std::string &branch, bool force)
640 : {
641 1 : if (!m_open) return ESYSREPO_RESULT(ResultCode::GIT_GENERIC_ERROR, "repository not open");
642 1 : if (branch.empty()) return ESYSREPO_RESULT(ResultCode::GIT_GENERIC_ERROR, "empty branch");
643 :
644 2 : std::vector<std::string> args = {"checkout"};
645 1 : if (force) args.emplace_back("-f");
646 2 : args.push_back(strip_rev_prefix(branch));
647 1 : return run_git(args, m_folder);
648 1 : }
649 :
650 1 : Result Git::reset(const git::CommitHash &commit, git::ResetType type)
651 : {
652 1 : if (!m_open) return ESYSREPO_RESULT(ResultCode::GIT_GENERIC_ERROR, "repository not open");
653 1 : if (commit.get_hash().empty()) return ESYSREPO_RESULT(ResultCode::GIT_GENERIC_ERROR, "empty commit");
654 :
655 1 : const char *mode = nullptr;
656 1 : switch (type)
657 : {
658 0 : case git::ResetType::NOT_SET: return ESYSREPO_RESULT(ResultCode::GIT_RESET_TYPE_NOT_SET);
659 : case git::ResetType::SOFT: mode = "--soft"; break;
660 0 : case git::ResetType::MIXED: mode = "--mixed"; break;
661 0 : case git::ResetType::HARD: mode = "--hard"; break;
662 0 : default: return ESYSREPO_RESULT(ResultCode::GIT_RESET_TYPE_UNKNOWN);
663 : }
664 4 : return run_git({"reset", mode, commit.get_hash()}, m_folder);
665 : }
666 :
667 1 : Result Git::fastforward(const git::CommitHash &commit)
668 : {
669 1 : if (!m_open) return ESYSREPO_RESULT(ResultCode::GIT_GENERIC_ERROR, "repository not open");
670 1 : if (commit.get_hash().empty()) return ESYSREPO_RESULT(ResultCode::GIT_GENERIC_ERROR, "empty commit");
671 : // Matches libgit2 path: move HEAD + worktree to the tip (FF only).
672 4 : return run_git({"merge", "--ff-only", commit.get_hash()}, m_folder);
673 : }
674 :
675 3 : Result Git::get_last_commit(git::CommitHash &commit)
676 : {
677 3 : if (!m_open) return ESYSREPO_RESULT(ResultCode::GIT_GENERIC_ERROR, "repository not open");
678 3 : std::string out;
679 12 : auto result = run_git_out({"rev-parse", "HEAD"}, m_folder, out);
680 3 : if (result.error()) return ESYSREPO_RESULT(result);
681 6 : commit.set_hash(trim(out));
682 3 : return ESYSREPO_RESULT(ResultCode::OK);
683 6 : }
684 :
685 1 : Result Git::get_last_commit(git::Commit &commit, bool /*get_all_notes*/)
686 : {
687 1 : if (!m_open) return ESYSREPO_RESULT(ResultCode::GIT_GENERIC_ERROR, "repository not open");
688 :
689 1 : commit.clear();
690 : // Notes stay empty on gitcmdline (use hybrid/libgit2 for note-heavy cmds).
691 : // Parse cat-file (not --format=%…): shell_quote doubles '%' for cmd.exe, which
692 : // breaks git pretty placeholders (%% becomes a literal '%' in the format).
693 1 : git::CommitHash hash;
694 1 : auto result = get_last_commit(hash);
695 1 : if (result.error()) return ESYSREPO_RESULT(result);
696 1 : commit.set_hash(hash.get_hash());
697 :
698 1 : std::string out;
699 4 : result = run_git_out({"cat-file", "-p", "HEAD"}, m_folder, out);
700 1 : if (result.error()) return ESYSREPO_RESULT(result);
701 :
702 3 : auto parse_ident = [](const std::string &line, git::Signature &sig) {
703 : // author Name <email> unix_ts tz
704 2 : const auto lt = line.find('<');
705 2 : const auto gt = line.find('>');
706 2 : if (lt == std::string::npos || gt == std::string::npos || gt <= lt) return;
707 4 : sig.set_name(trim(line.substr(0, lt)));
708 2 : sig.set_email(line.substr(lt + 1, gt - lt - 1));
709 2 : const std::string rest = trim(line.substr(gt + 1));
710 2 : const auto sp = rest.find(' ');
711 2 : try
712 : {
713 4 : const auto epoch = static_cast<std::time_t>(std::stoll(sp == std::string::npos ? rest : rest.substr(0, sp)));
714 2 : sig.set_date_time(std::chrono::system_clock::from_time_t(epoch));
715 : }
716 0 : catch (...)
717 : {
718 0 : }
719 2 : };
720 :
721 1 : std::string message;
722 1 : bool in_message = false;
723 7 : for (const auto &line : split_lines(out))
724 : {
725 6 : if (in_message)
726 : {
727 1 : if (!message.empty()) message.push_back('\n');
728 1 : message += line;
729 1 : continue;
730 : }
731 5 : if (line.empty())
732 : {
733 1 : in_message = true;
734 1 : continue;
735 : }
736 4 : if (line.rfind("author ", 0) == 0)
737 2 : parse_ident(line.substr(7), commit.get_author_sign());
738 3 : else if (line.rfind("committer ", 0) == 0)
739 2 : parse_ident(line.substr(10), commit.get_committer_sign());
740 1 : }
741 :
742 1 : while (!message.empty() && (message.back() == '\n' || message.back() == '\r')) message.pop_back();
743 1 : commit.set_message(message);
744 1 : const auto nl = message.find('\n');
745 1 : if (nl == std::string::npos)
746 : {
747 1 : commit.set_summary(message);
748 2 : commit.set_body({});
749 : }
750 : else
751 : {
752 0 : commit.set_summary(message.substr(0, nl));
753 0 : std::string body = message.substr(nl + 1);
754 0 : while (!body.empty() && (body.front() == '\n' || body.front() == '\r')) body.erase(body.begin());
755 0 : commit.set_body(body);
756 0 : }
757 :
758 1 : return ESYSREPO_RESULT(ResultCode::OK);
759 2 : }
760 :
761 1 : Result Git::get_parent_commit(const git::CommitHash &commit, git::CommitHash &parent, int nth_parent)
762 : {
763 1 : if (!m_open) return ESYSREPO_RESULT(ResultCode::GIT_GENERIC_ERROR, "repository not open");
764 1 : if (commit.get_hash().empty()) return ESYSREPO_RESULT(ResultCode::GIT_GENERIC_ERROR, "empty commit");
765 :
766 1 : if (nth_parent == 0)
767 : {
768 0 : parent = commit;
769 0 : return ESYSREPO_RESULT(ResultCode::OK);
770 : }
771 1 : if (nth_parent < 0) return ESYSREPO_RESULT(ResultCode::GIT_GENERIC_ERROR, "invalid nth_parent");
772 :
773 : // First-parent walk (matches libgit2 get_parent_commit).
774 1 : std::string out;
775 1 : auto result =
776 4 : run_git_out({"rev-parse", commit.get_hash() + "~" + std::to_string(nth_parent)}, m_folder, out);
777 1 : if (result.error()) return ESYSREPO_RESULT(result);
778 2 : parent.set_hash(trim(out));
779 1 : return ESYSREPO_RESULT(ResultCode::OK);
780 2 : }
781 :
782 1 : Result Git::is_dirty(bool &dirty)
783 : {
784 1 : if (!m_open) return ESYSREPO_RESULT(ResultCode::GIT_GENERIC_ERROR, "repository not open");
785 1 : dirty = false;
786 1 : std::string out;
787 4 : auto result = run_git_out({"status", "--porcelain"}, m_folder, out);
788 1 : if (result.error()) return ESYSREPO_RESULT(result);
789 1 : dirty = !trim(out).empty();
790 1 : return ESYSREPO_RESULT(ResultCode::OK);
791 2 : }
792 :
793 1 : Result Git::is_detached(bool &detached)
794 : {
795 1 : if (!m_open) return ESYSREPO_RESULT(ResultCode::GIT_GENERIC_ERROR, "repository not open");
796 1 : detached = false;
797 : // symbolic-ref fails (nonzero) when HEAD is detached.
798 5 : auto result = run_git({"symbolic-ref", "-q", "HEAD"}, m_folder);
799 1 : detached = result.error();
800 1 : return ESYSREPO_RESULT(ResultCode::OK);
801 1 : }
802 :
803 1 : Result Git::get_status(git::RepoStatus &repo_status)
804 : {
805 1 : if (!m_open) return ESYSREPO_RESULT(ResultCode::GIT_GENERIC_ERROR, "repository not open");
806 :
807 1 : std::string out;
808 5 : auto result = run_git_out({"status", "--porcelain=v1", "-uall"}, m_folder, out);
809 1 : if (result.error()) return ESYSREPO_RESULT(result);
810 :
811 3 : for (const auto &line : split_lines(out))
812 : {
813 2 : if (line.size() < 2) continue;
814 2 : const char x = line[0];
815 2 : const char y = line[1];
816 2 : std::string old_path;
817 2 : std::string new_path;
818 2 : if (!parse_porcelain_paths(line.substr(2), old_path, new_path)) continue;
819 :
820 2 : if (x == '?' && y == '?')
821 : {
822 1 : add_porcelain_status(repo_status, git::StatusType::WORKING_DIR, git::StatusSubType::NEW,
823 : git::DiffDeltaType::UNTRACKED, old_path, new_path);
824 1 : continue;
825 : }
826 1 : if (x == '!' && y == '!')
827 : {
828 0 : add_porcelain_status(repo_status, git::StatusType::IGNORED, git::StatusSubType::NOT_SET,
829 : git::DiffDeltaType::IGNORED, old_path, new_path);
830 0 : continue;
831 : }
832 1 : if (x == 'U' || y == 'U' || (x == 'A' && y == 'A') || (x == 'D' && y == 'D'))
833 : {
834 0 : add_porcelain_status(repo_status, git::StatusType::CONFLICTED, git::StatusSubType::NOT_SET,
835 : git::DiffDeltaType::CONFLICTED, old_path, new_path);
836 0 : continue;
837 : }
838 :
839 1 : if (x != ' ' && x != '?')
840 : {
841 0 : add_porcelain_status(repo_status, git::StatusType::INDEX, porcelain_to_subtype(x),
842 : porcelain_to_delta_type(x, false), old_path, new_path);
843 : }
844 1 : if (y != ' ' && y != '?')
845 : {
846 2 : add_porcelain_status(repo_status, git::StatusType::WORKING_DIR, porcelain_to_subtype(y),
847 : porcelain_to_delta_type(y, true), old_path, new_path);
848 : }
849 4 : }
850 :
851 1 : return ESYSREPO_RESULT(ResultCode::OK);
852 2 : }
853 :
854 0 : Result_t<bool> Git::is_ssh_agent_running(bool log_once)
855 : {
856 0 : detect_ssh_agent(log_once);
857 0 : return ESYSREPO_RESULT_T(ResultCode::OK, s_ssh_agent_running);
858 : }
859 :
860 0 : void Git::detect_ssh_agent(bool /*log_once*/)
861 : {
862 0 : if (s_detect_ssh_agent_done) return;
863 0 : s_detect_ssh_agent_done = true;
864 : #ifdef _WIN32
865 : // Pageant / Windows OpenSSH agent: presence of SSH_AUTH_SOCK or ssh-agent service is enough for CLI git.
866 : const char *sock = std::getenv("SSH_AUTH_SOCK");
867 : s_ssh_agent_running = (sock != nullptr && sock[0] != '\0') || (std::getenv("SSH_AGENT_PID") != nullptr);
868 : #else
869 0 : const char *sock = std::getenv("SSH_AUTH_SOCK");
870 0 : s_ssh_agent_running = (sock != nullptr && sock[0] != '\0');
871 : #endif
872 : }
873 :
874 2 : Result Git::merge_analysis(const std::vector<std::string> &refs, git::MergeAnalysisResult &merge_analysis_result,
875 : std::vector<git::CommitHash> &commits)
876 : {
877 2 : if (!m_open) return ESYSREPO_RESULT(ResultCode::GIT_GENERIC_ERROR, "repository not open");
878 2 : merge_analysis_result = git::MergeAnalysisResult::NOT_SET;
879 2 : commits.clear();
880 2 : if (refs.empty()) return ESYSREPO_RESULT(ResultCode::GIT_GENERIC_ERROR, "empty refs");
881 :
882 2 : std::string head;
883 8 : auto result = run_git_out({"rev-parse", "HEAD"}, m_folder, head);
884 2 : if (result.error()) return ESYSREPO_RESULT(result);
885 2 : head = trim(head);
886 :
887 : // Sync only uses the first upstream ref today; analyze that tip.
888 2 : const std::string &ref = refs.front();
889 2 : std::string target;
890 6 : result = run_git_out({"rev-parse", ref}, m_folder, target);
891 2 : if (result.error()) return ESYSREPO_RESULT(result);
892 2 : target = trim(target);
893 :
894 2 : git::CommitHash target_commit;
895 2 : target_commit.set_hash(target);
896 2 : commits.push_back(target_commit);
897 :
898 2 : if (head == target)
899 : {
900 1 : merge_analysis_result = git::MergeAnalysisResult::UP_TO_DATE;
901 1 : return ESYSREPO_RESULT(ResultCode::OK);
902 : }
903 :
904 1 : std::string base;
905 4 : result = run_git_out({"merge-base", "HEAD", ref}, m_folder, base);
906 1 : if (result.error()) return ESYSREPO_RESULT(result);
907 1 : base = trim(base);
908 :
909 1 : if (base == head)
910 1 : merge_analysis_result = git::MergeAnalysisResult::FASTFORWARD;
911 0 : else if (base == target)
912 0 : merge_analysis_result = git::MergeAnalysisResult::UP_TO_DATE;
913 : else
914 0 : merge_analysis_result = git::MergeAnalysisResult::NORMAL;
915 :
916 1 : return ESYSREPO_RESULT(ResultCode::OK);
917 6 : }
918 :
919 0 : Result Git::fetch_all_notes(const std::string &)
920 : {
921 0 : return not_implemented();
922 : }
923 :
924 1 : Result_t<bool> Git::has_branch(const std::string &name, git::BranchType branch_type)
925 : {
926 1 : if (!m_open) return ESYSREPO_RESULT_T(ResultCode::GIT_GENERIC_ERROR, false);
927 1 : git::Branches branches;
928 1 : auto result = get_branches(branches, branch_type);
929 1 : if (result.error()) return ESYSREPO_RESULT_T(result, false);
930 1 : const std::string tip = strip_rev_prefix(name);
931 1 : for (const auto &b : branches.get())
932 : {
933 1 : if (b->get_name() == tip || b->get_name() == name || b->get_ref_name() == name)
934 1 : return ESYSREPO_RESULT_T(ResultCode::OK, true);
935 : }
936 0 : return ESYSREPO_RESULT_T(ResultCode::OK, false);
937 1 : }
938 :
939 0 : Result Git::get_hash(const std::string &revision, std::string &hash, git::BranchType /*branch_type*/)
940 : {
941 0 : if (!m_open) return ESYSREPO_RESULT(ResultCode::GIT_GENERIC_ERROR, "repository not open");
942 0 : hash.clear();
943 0 : std::string out;
944 0 : auto result = run_git_out({"rev-parse", revision}, m_folder, out);
945 0 : if (result.error()) return ESYSREPO_RESULT(result);
946 0 : hash = trim(out);
947 0 : return ESYSREPO_RESULT(ResultCode::OK);
948 0 : }
949 :
950 0 : Result Git::walk_commits(std::shared_ptr<git::WalkCommit>)
951 : {
952 0 : return not_implemented();
953 : }
954 :
955 0 : Result Git::diff(const git::CommitHash &, std::shared_ptr<git::Diff>)
956 : {
957 0 : return not_implemented();
958 : }
959 :
960 1 : Result Git::get_ahead_behind(git::AheadBehind &ahead_behind, const git::Branch &local_branch)
961 : {
962 1 : ahead_behind.set_ahead(0);
963 1 : ahead_behind.set_behind(0);
964 1 : if (!m_open) return ESYSREPO_RESULT(ResultCode::GIT_GENERIC_ERROR, "repository not open");
965 1 : if (local_branch.get_name().empty() || local_branch.get_remote_branch().empty())
966 0 : return ESYSREPO_RESULT(ResultCode::GIT_GENERIC_ERROR, "branch missing name or upstream");
967 :
968 1 : return get_ahead_behind(ahead_behind, local_branch.get_name(), local_branch.get_remote_branch());
969 : }
970 :
971 2 : Result Git::get_ahead_behind(git::AheadBehind &ahead_behind, const std::string &first_ref,
972 : const std::string &second_ref)
973 : {
974 2 : ahead_behind.set_ahead(0);
975 2 : ahead_behind.set_behind(0);
976 2 : if (!m_open) return ESYSREPO_RESULT(ResultCode::GIT_GENERIC_ERROR, "repository not open");
977 2 : if (first_ref.empty() || second_ref.empty())
978 0 : return ESYSREPO_RESULT(ResultCode::GIT_GENERIC_ERROR, "empty ref");
979 :
980 2 : std::string out;
981 12 : auto result = run_git_out({"rev-list", "--left-right", "--count", first_ref + "..." + second_ref}, m_folder, out);
982 2 : if (result.error()) return ESYSREPO_RESULT(result);
983 :
984 2 : out = trim(out);
985 2 : const auto tab = out.find('\t');
986 2 : if (tab == std::string::npos) return ESYSREPO_RESULT(ResultCode::GIT_GENERIC_ERROR, "unexpected rev-list output");
987 :
988 2 : try
989 : {
990 4 : ahead_behind.set_ahead(static_cast<std::size_t>(std::stoull(out.substr(0, tab))));
991 4 : ahead_behind.set_behind(static_cast<std::size_t>(std::stoull(out.substr(tab + 1))));
992 : }
993 0 : catch (...)
994 : {
995 0 : return ESYSREPO_RESULT(ResultCode::GIT_GENERIC_ERROR, "failed to parse ahead/behind counts");
996 0 : }
997 2 : return ESYSREPO_RESULT(ResultCode::OK);
998 4 : }
999 :
1000 0 : Result Git::add_note(git::NoteId &, const std::string &, const std::string &, bool)
1001 : {
1002 0 : return not_implemented();
1003 : }
1004 :
1005 0 : Result Git::remove_note(const git::NoteId &)
1006 : {
1007 0 : return not_implemented();
1008 : }
1009 :
1010 0 : Result Git::remove_note(const git::CommitHash &, const std::string &)
1011 : {
1012 0 : return not_implemented();
1013 : }
1014 :
1015 0 : Result Git::remove_note_last_commit(const std::string &)
1016 : {
1017 0 : return not_implemented();
1018 : }
1019 :
1020 0 : Result Git::push(const std::string &, const std::string &, const std::string &)
1021 : {
1022 0 : return not_implemented();
1023 : }
1024 :
1025 0 : Result Git::push_notes(const std::string &, const std::string &)
1026 : {
1027 0 : return not_implemented();
1028 : }
1029 :
1030 0 : const std::string &Git::get_version()
1031 : {
1032 0 : return s_get_version();
1033 : }
1034 :
1035 0 : const std::string &Git::get_lib_name()
1036 : {
1037 0 : return s_get_lib_name();
1038 : }
1039 :
1040 0 : const std::string &Git::s_get_version()
1041 : {
1042 0 : static const std::string version = "git-cmdline";
1043 0 : return version;
1044 : }
1045 :
1046 0 : const std::string &Git::s_get_lib_name()
1047 : {
1048 0 : static const std::string name = "gitcmdline";
1049 0 : return name;
1050 : }
1051 :
1052 0 : bool Git::do_is_ssh_backend_supported(git::SshBackend backend) const
1053 : {
1054 0 : return backend == git::SshBackend::Exec;
1055 : }
1056 :
1057 0 : bool Git::do_is_ssh_backend_available(git::SshBackend backend, bool /*force*/)
1058 : {
1059 0 : if (backend != git::SshBackend::Exec) return false;
1060 0 : return !find_git_executable().empty();
1061 : }
1062 :
1063 0 : Result Git::do_set_ssh_backend(git::SshBackend backend)
1064 : {
1065 0 : if (backend != git::SshBackend::Exec)
1066 0 : return ESYSREPO_RESULT(ResultCode::GIT_SSH_BACKEND_NOT_SUPPORTED);
1067 0 : m_ssh_backend = backend;
1068 0 : return ESYSREPO_RESULT(ResultCode::OK);
1069 : }
1070 :
1071 0 : Result_t<git::SshBackend> Git::do_get_ssh_backend() const
1072 : {
1073 0 : return ESYSREPO_RESULT_T(ResultCode::OK, m_ssh_backend);
1074 : }
1075 :
1076 : } // namespace esys::repo::gitcmdline
|