federaide-dev-nightly 2.0.1__tar.gz → 2.0.2__tar.gz

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.
Files changed (40) hide show
  1. {federaide_dev_nightly-2.0.1/src/federaide_dev_nightly.egg-info → federaide_dev_nightly-2.0.2}/PKG-INFO +1 -1
  2. {federaide_dev_nightly-2.0.1 → federaide_dev_nightly-2.0.2}/pyproject.toml +1 -1
  3. {federaide_dev_nightly-2.0.1 → federaide_dev_nightly-2.0.2/src/federaide_dev_nightly.egg-info}/PKG-INFO +1 -1
  4. {federaide_dev_nightly-2.0.1 → federaide_dev_nightly-2.0.2}/src/federate/agent.py +170 -51
  5. {federaide_dev_nightly-2.0.1 → federaide_dev_nightly-2.0.2}/src/federate/toolbox.py +120 -61
  6. {federaide_dev_nightly-2.0.1 → federaide_dev_nightly-2.0.2}/LICENSE +0 -0
  7. {federaide_dev_nightly-2.0.1 → federaide_dev_nightly-2.0.2}/MANIFEST.in +0 -0
  8. {federaide_dev_nightly-2.0.1 → federaide_dev_nightly-2.0.2}/README.md +0 -0
  9. {federaide_dev_nightly-2.0.1 → federaide_dev_nightly-2.0.2}/go/cmd/bridge/main.go +0 -0
  10. {federaide_dev_nightly-2.0.1 → federaide_dev_nightly-2.0.2}/go/cmd/embed/main.go +0 -0
  11. {federaide_dev_nightly-2.0.1 → federaide_dev_nightly-2.0.2}/go/cmd/search/main.go +0 -0
  12. {federaide_dev_nightly-2.0.1 → federaide_dev_nightly-2.0.2}/go/go.mod +0 -0
  13. {federaide_dev_nightly-2.0.1 → federaide_dev_nightly-2.0.2}/go/go.sum +0 -0
  14. {federaide_dev_nightly-2.0.1 → federaide_dev_nightly-2.0.2}/scripts/build_go.sh +0 -0
  15. {federaide_dev_nightly-2.0.1 → federaide_dev_nightly-2.0.2}/setup.cfg +0 -0
  16. {federaide_dev_nightly-2.0.1 → federaide_dev_nightly-2.0.2}/setup.py +0 -0
  17. {federaide_dev_nightly-2.0.1 → federaide_dev_nightly-2.0.2}/src/federaide_dev_nightly.egg-info/SOURCES.txt +0 -0
  18. {federaide_dev_nightly-2.0.1 → federaide_dev_nightly-2.0.2}/src/federaide_dev_nightly.egg-info/dependency_links.txt +0 -0
  19. {federaide_dev_nightly-2.0.1 → federaide_dev_nightly-2.0.2}/src/federaide_dev_nightly.egg-info/entry_points.txt +0 -0
  20. {federaide_dev_nightly-2.0.1 → federaide_dev_nightly-2.0.2}/src/federaide_dev_nightly.egg-info/requires.txt +0 -0
  21. {federaide_dev_nightly-2.0.1 → federaide_dev_nightly-2.0.2}/src/federaide_dev_nightly.egg-info/top_level.txt +0 -0
  22. {federaide_dev_nightly-2.0.1 → federaide_dev_nightly-2.0.2}/src/federate/__init__.py +0 -0
  23. {federaide_dev_nightly-2.0.1 → federaide_dev_nightly-2.0.2}/src/federate/__main__.py +0 -0
  24. {federaide_dev_nightly-2.0.1 → federaide_dev_nightly-2.0.2}/src/federate/agent_core.py +0 -0
  25. {federaide_dev_nightly-2.0.1 → federaide_dev_nightly-2.0.2}/src/federate/agent_legacy.py +0 -0
  26. {federaide_dev_nightly-2.0.1 → federaide_dev_nightly-2.0.2}/src/federate/app.py +0 -0
  27. {federaide_dev_nightly-2.0.1 → federaide_dev_nightly-2.0.2}/src/federate/audio_handler.py +0 -0
  28. {federaide_dev_nightly-2.0.1 → federaide_dev_nightly-2.0.2}/src/federate/chatgpt_auth.py +0 -0
  29. {federaide_dev_nightly-2.0.1 → federaide_dev_nightly-2.0.2}/src/federate/commands.py +0 -0
  30. {federaide_dev_nightly-2.0.1 → federaide_dev_nightly-2.0.2}/src/federate/execution.py +0 -0
  31. {federaide_dev_nightly-2.0.1 → federaide_dev_nightly-2.0.2}/src/federate/mcp_handler.py +0 -0
  32. {federaide_dev_nightly-2.0.1 → federaide_dev_nightly-2.0.2}/src/federate/orchestration.py +0 -0
  33. {federaide_dev_nightly-2.0.1 → federaide_dev_nightly-2.0.2}/src/federate/semantic_search.py +0 -0
  34. {federaide_dev_nightly-2.0.1 → federaide_dev_nightly-2.0.2}/src/federate/styles/fonts/Michroma-Regular.ttf +0 -0
  35. {federaide_dev_nightly-2.0.1 → federaide_dev_nightly-2.0.2}/src/federate/styles/fonts/SpaceGrotesk-Bold.ttf +0 -0
  36. {federaide_dev_nightly-2.0.1 → federaide_dev_nightly-2.0.2}/src/federate/styles/fonts/SpaceGrotesk-VariableFont_wght.ttf +0 -0
  37. {federaide_dev_nightly-2.0.1 → federaide_dev_nightly-2.0.2}/src/federate/styles/style.css +0 -0
  38. {federaide_dev_nightly-2.0.1 → federaide_dev_nightly-2.0.2}/src/federate/subagents.py +0 -0
  39. {federaide_dev_nightly-2.0.1 → federaide_dev_nightly-2.0.2}/src/federate/telegram_handler.py +0 -0
  40. {federaide_dev_nightly-2.0.1 → federaide_dev_nightly-2.0.2}/src/federate/textIDE.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: federaide-dev-nightly
3
- Version: 2.0.1
3
+ Version: 2.0.2
4
4
  Summary: A cross-platform terminal based p2p agent orchestration harness and universal automation system
5
5
  Author-email: ROCK-LAB-PRIVATE-LIMITED <arkadeep_ghosh@rocklab.in>
6
6
  License: GNU Affero General Public License v3
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "federaide-dev-nightly"
7
- version = "2.0.1"
7
+ version = "2.0.2"
8
8
  description = "A cross-platform terminal based p2p agent orchestration harness and universal automation system"
9
9
  readme = "README.md"
10
10
  license = {text = "GNU Affero General Public License v3"}
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: federaide-dev-nightly
3
- Version: 2.0.1
3
+ Version: 2.0.2
4
4
  Summary: A cross-platform terminal based p2p agent orchestration harness and universal automation system
5
5
  Author-email: ROCK-LAB-PRIVATE-LIMITED <arkadeep_ghosh@rocklab.in>
6
6
  License: GNU Affero General Public License v3
@@ -294,6 +294,45 @@ class ClarificationModal(ModalScreen[str]):
294
294
  def on_cancel(self):
295
295
  self.dismiss("")
296
296
 
297
+ def get_git_commit_times(team_dir: str = None) -> dict:
298
+ times = {}
299
+ t_dir = team_dir or toolbox.get_team_dir()
300
+ try:
301
+ res = subprocess.run(
302
+ ["git", "log", "--format=COMMIT:%ct", "--name-only", "--", "sessions", "nomem_sessions"],
303
+ cwd=t_dir,
304
+ capture_output=True,
305
+ text=True,
306
+ timeout=3
307
+ )
308
+ if res.returncode == 0:
309
+ cur_time = 0.0
310
+ for line in res.stdout.splitlines():
311
+ line = line.strip()
312
+ if line.startswith("COMMIT:"):
313
+ try: cur_time = float(line.split(":")[1])
314
+ except Exception: cur_time = 0.0
315
+ elif line and cur_time:
316
+ abs_p = os.path.normpath(os.path.join(t_dir, line))
317
+ if abs_p not in times:
318
+ times[abs_p] = cur_time
319
+ except Exception:
320
+ pass
321
+ return times
322
+
323
+ def get_session_file_time(fpath: str, git_times: dict = None) -> float:
324
+ norm = os.path.normpath(fpath)
325
+ if git_times and norm in git_times:
326
+ return git_times[norm]
327
+ parts = os.path.basename(fpath).replace(".json", "").split("_")
328
+ for p in parts:
329
+ if p.isdigit() and len(p) >= 9:
330
+ return float(p)
331
+ try:
332
+ return os.path.getmtime(fpath)
333
+ except Exception:
334
+ return 0.0
335
+
297
336
  class ChatLoadModal(ModalScreen[str]):
298
337
  DEFAULT_CSS = """
299
338
  ChatLoadModal { align: center middle; background: $background 60%; }
@@ -310,7 +349,9 @@ class ChatLoadModal(ModalScreen[str]):
310
349
  def compose(self) -> ComposeResult:
311
350
  files_normal = glob.glob(toolbox.get_storage_path("sessions", "*.json"))
312
351
  files_nomem = glob.glob(toolbox.get_storage_path("nomem_sessions", "*.json"))
313
- files = sorted(files_normal + files_nomem, key=os.path.getmtime, reverse=True)
352
+ raw_files = sorted(files_normal + files_nomem)
353
+ git_times = get_git_commit_times()
354
+ files = sorted(raw_files, key=lambda f: get_session_file_time(f, git_times), reverse=True)
314
355
  name_map = agent_core.get_session_name_map()
315
356
 
316
357
  def _extract_info(fpath):
@@ -1631,10 +1672,42 @@ class TeamDeleteModal(ModalScreen[str]):
1631
1672
  else:
1632
1673
  self.dismiss("cancel")
1633
1674
 
1675
+ class GitConflictModal(ModalScreen[str]):
1676
+ DEFAULT_CSS = """
1677
+ GitConflictModal { align: center middle; background: $background 60%; }
1678
+ #conflict_dialog { width: 70; height: auto; border: thick $error; background: $surface; padding: 1 2; }
1679
+ .conflict_msg { margin: 1 0; text-wrap: wrap; height: auto; }
1680
+ .conflict_buttons { layout: horizontal; height: auto; align: right middle; margin-top: 1; }
1681
+ .conflict_buttons Button { margin-left: 1; }
1682
+ """
1683
+ def compose(self) -> ComposeResult:
1684
+ with Vertical(id="conflict_dialog"):
1685
+ yield Label(" Git History Conflict Detected!", classes="pane_title")
1686
+ yield Label(
1687
+ "The local and remote repository histories have diverged and cannot be automatically merged.\n\n"
1688
+ "Which branch would you like to keep?\n"
1689
+ "• Keep Local: Overwrites remote with your local files (Force Push).\n"
1690
+ "• Keep Remote: Overwrites local files with the remote repository.",
1691
+ classes="conflict_msg"
1692
+ )
1693
+ with Horizontal(classes="conflict_buttons"):
1694
+ yield Button("Keep Local (Force Push)", id="btn_keep_local", variant="success")
1695
+ yield Button("Keep Remote (Reset)", id="btn_keep_remote", variant="warning")
1696
+ yield Button("Cancel", id="btn_abort", variant="error")
1697
+
1698
+ @on(Button.Pressed)
1699
+ def handle_press(self, event: Button.Pressed):
1700
+ if event.button.id == "btn_keep_local":
1701
+ self.dismiss("local")
1702
+ elif event.button.id == "btn_keep_remote":
1703
+ self.dismiss("remote")
1704
+ else:
1705
+ self.dismiss("abort")
1706
+
1634
1707
  class TeamsModal(ModalScreen[None]):
1635
1708
  DEFAULT_CSS = """
1636
1709
  TeamsModal { align: center middle; background: $background 60%; }
1637
- #teams_dialog { width: 80; height: 85%; border: thick $primary; background: $surface; padding: 1 2; }
1710
+ #teams_dialog { width: 88; height: 85%; border: thick $primary; background: $surface; padding: 1 2; }
1638
1711
  #teams_main_scroll { height: 1fr; border: round $accent; padding: 1; background: $boost; margin-bottom: 1; }
1639
1712
  #team_form { height: auto; margin-bottom: 1; }
1640
1713
  #team_list { height: auto; }
@@ -1647,8 +1720,8 @@ class TeamsModal(ModalScreen[None]):
1647
1720
  #team_folder_input { width: 1fr; }
1648
1721
  #team_git_user_input { width: 1fr; margin-right: 1; }
1649
1722
  #team_git_pat_input { width: 1fr; }
1650
- .team_form_btns { layout: horizontal; height: auto; margin-top: 1; align: right middle; }
1651
- .team_form_btns Button { margin-left: 1; }
1723
+ .team_form_row { layout: horizontal; height: auto; margin-top: 1; width: 100%; }
1724
+ .team_form_row Button { width: 1fr; margin: 0 1; }
1652
1725
  #teams_bottom_buttons { height: auto; align: right middle; margin-top: 0; }
1653
1726
  #teams_bottom_buttons Button { margin-left: 1; }
1654
1727
  """
@@ -1674,11 +1747,15 @@ class TeamsModal(ModalScreen[None]):
1674
1747
  yield Input(placeholder="Git Username", id="team_git_user_input")
1675
1748
  yield Input(placeholder="Personal Access Token (PAT)", id="team_git_pat_input", password=True)
1676
1749
 
1677
- with Horizontal(classes="team_form_btns"):
1750
+ with Horizontal(classes="team_form_row"):
1678
1751
  yield Button("Save Team", id="save_team_btn", variant="success")
1679
1752
  yield Button("Restore", id="restore_btn", variant="primary")
1680
- yield Button("Sync Now", id="sync_now_btn", variant="warning")
1681
1753
  yield Button("Clear Form", id="clear_form_btn", variant="default")
1754
+
1755
+ with Horizontal(classes="team_form_row"):
1756
+ yield Button("Pull", id="pull_btn", variant="warning")
1757
+ yield Button("Push", id="push_btn", variant="warning")
1758
+ yield Button("Sync", id="sync_now_btn", variant="warning")
1682
1759
 
1683
1760
  yield Label("Registered Teams:", classes="section_label")
1684
1761
  yield Vertical(id="team_list")
@@ -1715,7 +1792,58 @@ class TeamsModal(ModalScreen[None]):
1715
1792
  self.query_one("#team_remote_url_input", Input).value = ""
1716
1793
  self.query_one("#team_git_user_input", Input).value = ""
1717
1794
  self.query_one("#team_git_pat_input", Input).value = ""
1718
- self.query_one("#save_team_btn", Button).label = "Create"
1795
+ self.query_one("#save_team_btn", Button).label = "Save Team"
1796
+
1797
+ def _handle_git_action(self, action_name: str, target_team_dir: str):
1798
+ def _run_action():
1799
+ try:
1800
+
1801
+ ok, msg = toolbox.sync_team_remote(action=action_name, team_dir=target_team_dir)
1802
+ if ok:
1803
+ self.app.call_from_thread(self.notify, f"Git {action_name} completed successfully!", severity="information")
1804
+ if action_name in ["pull", "sync", "hard-reset-remote"]:
1805
+ self.agent_view.agent_manager.load_agents()
1806
+ self.agent_view.session_manager.sync_all_sessions()
1807
+ self.agent_view.update_status_bar()
1808
+ self.app.call_from_thread(self.refresh_list)
1809
+ elif msg == "CONFLICT":
1810
+ def _on_conflict():
1811
+ def _resolve(choice):
1812
+ if choice == "local":
1813
+ self.notify("Overwriting remote with local state (force push)...", severity="warning")
1814
+ self._handle_git_action("force-push", target_team_dir)
1815
+ elif choice == "remote":
1816
+ self.notify("Overwriting local state with remote...", severity="warning")
1817
+ self._handle_git_action("hard-reset-remote", target_team_dir)
1818
+ else:
1819
+ self.notify("Git operation cancelled by user.", severity="information")
1820
+ _reenable()
1821
+ self.app.push_screen(GitConflictModal(), _resolve)
1822
+ self.app.call_from_thread(_on_conflict)
1823
+ return
1824
+ else:
1825
+ self.app.call_from_thread(self.notify, f"Git {action_name} Failed: {msg}", severity="error", timeout=10.0)
1826
+ except Exception as e:
1827
+ self.app.call_from_thread(self.notify, f"Git Error: {e}", severity="error", timeout=10.0)
1828
+ finally:
1829
+ if msg != "CONFLICT":
1830
+ self.app.call_from_thread(_reenable)
1831
+
1832
+ def _reenable():
1833
+ try:
1834
+ self.query_one("#sync_now_btn", Button).disabled = False
1835
+ self.query_one("#pull_btn", Button).disabled = False
1836
+ self.query_one("#push_btn", Button).disabled = False
1837
+ except Exception: pass
1838
+
1839
+ try:
1840
+ self.query_one("#sync_now_btn", Button).disabled = True
1841
+ self.query_one("#pull_btn", Button).disabled = True
1842
+ self.query_one("#push_btn", Button).disabled = True
1843
+ except Exception: pass
1844
+
1845
+ import threading
1846
+ threading.Thread(target=_run_action, daemon=True).start()
1719
1847
 
1720
1848
  def refresh_list(self):
1721
1849
  container = self.query_one("#team_list")
@@ -1786,42 +1914,53 @@ class TeamsModal(ModalScreen[None]):
1786
1914
  def _run_restore():
1787
1915
  try:
1788
1916
  from urllib.parse import urlparse, urlunparse, quote
1789
- import subprocess
1917
+ import subprocess, tempfile, stat, contextlib, os
1790
1918
 
1919
+ @contextlib.contextmanager
1920
+ def git_askpass_context(pat_str: str):
1921
+ askpass_ext = ".bat" if os.name == "nt" else ".sh"
1922
+ fd, askpass_path = tempfile.mkstemp(suffix=askpass_ext, text=True)
1923
+ with os.fdopen(fd, "w") as f:
1924
+ if os.name == "nt":
1925
+ f.write("@echo off\nif \"%~1\"==\"\" exit /b 0\necho %FEDERAIDE_GIT_PASSWORD%\n")
1926
+ else:
1927
+ f.write("#!/bin/sh\necho \"$FEDERAIDE_GIT_PASSWORD\"\n")
1928
+ if os.name != "nt":
1929
+ os.chmod(askpass_path, stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR)
1930
+
1931
+ env = os.environ.copy()
1932
+ env["GIT_ASKPASS"] = askpass_path
1933
+ env["FEDERAIDE_GIT_PASSWORD"] = pat_str
1934
+ env["GIT_TERMINAL_PROMPT"] = "0"
1935
+ try:
1936
+ yield env
1937
+ finally:
1938
+ try: os.remove(askpass_path)
1939
+ except Exception: pass
1940
+
1791
1941
  parsed = urlparse(url)
1792
1942
  user_part = quote(user, safe='') if user else "git"
1793
- pat_part = quote(pat, safe='')
1794
- auth_netloc = f"{user_part}:{pat_part}@{parsed.netloc}"
1795
- authed_url = urlunparse((parsed.scheme, auth_netloc, parsed.path, parsed.params, parsed.query, parsed.fragment))
1943
+ auth_netloc = f"{user_part}@{parsed.netloc}"
1944
+ safe_url = urlunparse((parsed.scheme, auth_netloc, parsed.path, parsed.params, parsed.query, parsed.fragment))
1796
1945
 
1797
- res = subprocess.run(["git", "clone", authed_url, target_path], capture_output=True, text=True)
1946
+ with git_askpass_context(pat) as env:
1947
+ res = subprocess.run(["git", "clone", safe_url, target_path], capture_output=True, text=True, env=env)
1798
1948
 
1799
1949
  if res.returncode == 0:
1800
- # SECURITY FIX: Remove PAT from .git/config immediately after cloning
1801
1950
  subprocess.run(["git", "remote", "set-url", "origin", url], cwd=target_path, capture_output=True)
1802
1951
  def _on_success():
1803
1952
  self.notify("Restore completed successfully!", severity="information")
1804
1953
  t_name = self.query_one("#team_name_input", Input).value.strip() or os.path.basename(target_path)
1805
1954
  self.dismiss()
1806
1955
 
1807
- # Switch to the newly cloned workspace
1808
1956
  self.agent_view.switch_team(target_path, display_name=t_name)
1809
-
1810
- # Save credentials securely into the keyring
1811
- import toolbox
1812
1957
  toolbox.save_team_git_credentials(url, user, pat, team_dir=target_path)
1813
1958
 
1814
- # Rebuild episodic and tool results databases in the background
1815
- import threading
1816
1959
  threading.Thread(target=self.agent_view.session_manager.sync_all_sessions, daemon=True).start()
1817
1960
 
1818
1961
  self.app.call_from_thread(_on_success)
1819
1962
  else:
1820
- def _sanitize(txt: str) -> str:
1821
- if not txt: return ""
1822
- clean = txt.replace(pat, "******") if pat else txt
1823
- return clean.strip()
1824
- err = _sanitize(res.stderr) or _sanitize(res.stdout) or "Clone failed."
1963
+ err = res.stderr.strip() or res.stdout.strip() or "Clone failed."
1825
1964
  self.app.call_from_thread(self.notify, f"Restore Failed: {err}", severity="error", timeout=10.0)
1826
1965
  except Exception as e:
1827
1966
  self.app.call_from_thread(self.notify, f"Restore Error: {e}", severity="error", timeout=10.0)
@@ -1836,39 +1975,18 @@ class TeamsModal(ModalScreen[None]):
1836
1975
  import threading
1837
1976
  threading.Thread(target=_run_restore, daemon=True).start()
1838
1977
 
1839
- elif btn_id == "sync_now_btn":
1978
+ elif btn_id in ("sync_now_btn", "pull_btn", "push_btn"):
1840
1979
  url = self.query_one("#team_remote_url_input", Input).value.strip()
1841
1980
  user = self.query_one("#team_git_user_input", Input).value.strip()
1842
1981
  pat = self.query_one("#team_git_pat_input", Input).value.strip()
1843
1982
  target_team_dir = self.editing_path or toolbox.get_team_dir()
1844
1983
 
1845
1984
  toolbox.save_team_git_credentials(url, user, pat, team_dir=target_team_dir)
1846
- self.notify(f"Syncing with Git remote for '{os.path.basename(target_team_dir)}'...", severity="information")
1847
1985
 
1848
- sync_btn = self.query_one("#sync_now_btn", Button)
1849
- sync_btn.disabled = True
1850
-
1851
- def _run_sync():
1852
- try:
1853
- ok, msg = toolbox.sync_team_remote(action="sync", team_dir=target_team_dir)
1854
- if ok:
1855
- self.app.call_from_thread(self.notify, "Git remote sync completed successfully!", severity="information")
1856
- self.agent_view.agent_manager.load_agents()
1857
- self.agent_view.session_manager.sync_all_sessions()
1858
- self.agent_view.update_status_bar()
1859
- else:
1860
- self.app.call_from_thread(self.notify, f"Sync Failed: {msg}", severity="error", timeout=10.0)
1861
- except Exception as e:
1862
- self.app.call_from_thread(self.notify, f"Sync Error: {e}", severity="error", timeout=10.0)
1863
- finally:
1864
- def _reenable():
1865
- try:
1866
- self.query_one("#sync_now_btn", Button).disabled = False
1867
- except Exception:
1868
- pass
1869
- self.app.call_from_thread(_reenable)
1870
-
1871
- threading.Thread(target=_run_sync, daemon=True).start()
1986
+ action_map = {"sync_now_btn": "sync", "pull_btn": "pull", "push_btn": "push"}
1987
+ action = action_map[btn_id]
1988
+ self.notify(f"{action.title()}ing with Git remote for '{os.path.basename(target_team_dir)}'...", severity="information")
1989
+ self._handle_git_action(action, target_team_dir)
1872
1990
 
1873
1991
  elif btn_id == "save_team_btn":
1874
1992
  t_name = self.query_one("#team_name_input", Input).value.strip()
@@ -3457,9 +3575,10 @@ class AIAgentView(Vertical):
3457
3575
  self.log_to_ui("[bold red]No past chat sessions found to resume.[/bold red]")
3458
3576
  return
3459
3577
 
3578
+ git_times = get_git_commit_times()
3460
3579
  sorted_sessions = sorted(
3461
3580
  sessions_map.items(),
3462
- key=lambda item: max(os.path.getmtime(f) for f in item[1]),
3581
+ key=lambda item: max(get_session_file_time(f, git_times) for f in item[1]),
3463
3582
  reverse=True
3464
3583
  )
3465
3584
 
@@ -548,7 +548,7 @@ def save_team_git_credentials(remote_url: str, username: str, pat: str, team_dir
548
548
 
549
549
  def sync_team_remote(action: str = "push", team_dir: str = None) -> tuple[bool, str]:
550
550
  """
551
- Executes an in-memory authenticated Git remote operation (push, pull, force-push, sync).
551
+ Executes an authenticated Git remote operation.
552
552
  The PAT is dynamically read from Keyring and never written to disk or git configs.
553
553
  Returns (success, message).
554
554
  """
@@ -564,82 +564,141 @@ def sync_team_remote(action: str = "push", team_dir: str = None) -> tuple[bool,
564
564
 
565
565
  try:
566
566
  from urllib.parse import urlparse, urlunparse, quote
567
+ import tempfile, stat, contextlib
568
+
569
+ # 1. Commit any uncommitted changes first so git operations don't fail with "unstaged changes"
570
+ try:
571
+ flush_sqlite_databases()
572
+ subprocess.run(["git", "add", "."], cwd=t_dir, capture_output=True)
573
+ status = subprocess.run(["git", "status", "--porcelain"], cwd=t_dir, capture_output=True, text=True)
574
+ if status.stdout.strip():
575
+ subprocess.run(
576
+ ["git", "-c", "user.name=FEDERaiDE", "-c", "user.email=harness@local", "commit", "-m", "Auto-commit before remote sync"],
577
+ cwd=t_dir, capture_output=True
578
+ )
579
+ except Exception:
580
+ pass
581
+
567
582
  parsed = urlparse(remote_url)
568
583
  if not parsed.scheme or not parsed.netloc:
569
584
  return False, f"Invalid remote URL format: {remote_url}"
570
585
 
571
586
  user_part = quote(username.strip(), safe='') if username else "git"
572
- pat_part = quote(pat.strip(), safe='')
573
- auth_netloc = f"{user_part}:{pat_part}@{parsed.netloc}"
574
- authed_url = urlunparse((parsed.scheme, auth_netloc, parsed.path, parsed.params, parsed.query, parsed.fragment))
575
-
576
- env = os.environ.copy()
577
- env["GIT_TERMINAL_PROMPT"] = "0"
578
-
579
- # Define _sanitize immediately so it is in scope everywhere below
580
- def _sanitize(txt: str) -> str:
581
- if not txt: return ""
582
- clean = txt.replace(pat, "******")
583
- if pat_part != pat:
584
- clean = clean.replace(pat_part, "******")
585
- return clean.strip()
587
+ auth_netloc = f"{user_part}@{parsed.netloc}"
588
+ safe_url = urlunparse((parsed.scheme, auth_netloc, parsed.path, parsed.params, parsed.query, parsed.fragment))
586
589
 
587
590
  branch_res = subprocess.run(["git", "rev-parse", "--abbrev-ref", "HEAD"], cwd=t_dir, capture_output=True, text=True)
588
591
  branch = branch_res.stdout.strip() if branch_res.returncode == 0 and branch_res.stdout.strip() else "main"
589
592
  user_args = ["-c", "user.name=FEDERaiDE", "-c", "user.email=harness@local"]
590
593
 
591
- if action == "pull":
592
- cmd = ["git"] + user_args + ["pull", "--rebase", authed_url, branch]
593
- res = subprocess.run(cmd, cwd=t_dir, capture_output=True, text=True, env=env, timeout=120)
594
- if res.returncode == 0:
595
- return True, "Synchronized successfully."
596
- return False, _sanitize(res.stderr) or _sanitize(res.stdout) or f"Git exited with code {res.returncode}"
594
+ @contextlib.contextmanager
595
+ def git_askpass_context(pat_str: str):
596
+ askpass_ext = ".bat" if os.name == "nt" else ".sh"
597
+ fd, askpass_path = tempfile.mkstemp(suffix=askpass_ext, text=True)
598
+ with os.fdopen(fd, "w") as f:
599
+ if os.name == "nt":
600
+ f.write("@echo off\nif \"%~1\"==\"\" exit /b 0\necho %FEDERAIDE_GIT_PASSWORD%\n")
601
+ else:
602
+ f.write("#!/bin/sh\necho \"$FEDERAIDE_GIT_PASSWORD\"\n")
603
+ if os.name != "nt":
604
+ os.chmod(askpass_path, stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR)
605
+
606
+ env = os.environ.copy()
607
+ env["GIT_ASKPASS"] = askpass_path
608
+ env["FEDERAIDE_GIT_PASSWORD"] = pat_str
609
+ env["GIT_TERMINAL_PROMPT"] = "0"
610
+ try:
611
+ yield env
612
+ finally:
613
+ try: os.remove(askpass_path)
614
+ except Exception: pass
597
615
 
598
- elif action == "force-push":
599
- cmd = ["git", "push", "--force", authed_url, f"HEAD:{branch}"]
600
- res = subprocess.run(cmd, cwd=t_dir, capture_output=True, text=True, env=env, timeout=120)
601
- if res.returncode == 0:
602
- return True, "Synchronized successfully."
603
- return False, _sanitize(res.stderr) or _sanitize(res.stdout) or f"Git exited with code {res.returncode}"
616
+ with git_askpass_context(pat) as env:
617
+ if action == "pull":
618
+ cmd = ["git"] + user_args + ["pull", "--rebase", "--autostash", safe_url, branch]
619
+ res = subprocess.run(cmd, cwd=t_dir, capture_output=True, text=True, env=env, timeout=120)
620
+ if res.returncode == 0:
621
+ return True, "Synchronized successfully."
622
+
623
+ err_check = (res.stderr + res.stdout).lower()
624
+ subprocess.run(["git", "rebase", "--abort"], cwd=t_dir, capture_output=True, env=env)
625
+ subprocess.run(["git", "merge", "--abort"], cwd=t_dir, capture_output=True, env=env)
626
+
627
+ conflict_indicators = [
628
+ "conflict", "divergent", "unstaged changes", "cannot pull with rebase",
629
+ "unrelated histories", "failed to merge", "could not apply", "patch failed",
630
+ "non-fast-forward"
631
+ ]
632
+ if any(ind in err_check for ind in conflict_indicators):
633
+ return False, "CONFLICT"
634
+
635
+ return False, res.stderr.strip() or res.stdout.strip() or f"Git exited with code {res.returncode}"
636
+
637
+ elif action == "push":
638
+ cmd = ["git", "push", safe_url, f"HEAD:{branch}"]
639
+ res = subprocess.run(cmd, cwd=t_dir, capture_output=True, text=True, env=env, timeout=120)
640
+ if res.returncode == 0:
641
+ return True, "Pushed successfully."
642
+
643
+ err_check = (res.stderr + res.stdout).lower()
644
+ conflict_indicators = [
645
+ "non-fast-forward", "fetch first", "rejected", "behind", "conflict"
646
+ ]
647
+ if any(ind in err_check for ind in conflict_indicators):
648
+ return False, "CONFLICT"
649
+
650
+ return False, res.stderr.strip() or res.stdout.strip() or f"Git exited with code {res.returncode}"
651
+
652
+ elif action == "force-push":
653
+ cmd = ["git", "push", "--force", safe_url, f"HEAD:{branch}"]
654
+ res = subprocess.run(cmd, cwd=t_dir, capture_output=True, text=True, env=env, timeout=120)
655
+ if res.returncode == 0:
656
+ return True, "Force pushed successfully."
657
+ return False, res.stderr.strip() or res.stdout.strip() or f"Git exited with code {res.returncode}"
658
+
659
+ elif action == "hard-reset-remote":
660
+ fetch_cmd = ["git", "fetch", safe_url, branch]
661
+ fetch_res = subprocess.run(fetch_cmd, cwd=t_dir, capture_output=True, text=True, env=env, timeout=120)
662
+ if fetch_res.returncode != 0:
663
+ return False, fetch_res.stderr.strip() or fetch_res.stdout.strip() or "Fetch failed."
664
+
665
+ reset_cmd = ["git", "reset", "--hard", "FETCH_HEAD"]
666
+ res = subprocess.run(reset_cmd, cwd=t_dir, capture_output=True, text=True, env=env, timeout=120)
667
+ if res.returncode == 0:
668
+ return True, "Reset to remote state successfully."
669
+ return False, res.stderr.strip() or res.stdout.strip() or f"Git exited with code {res.returncode}"
670
+
671
+ elif action == "sync":
672
+ # 1. Try pushing first. If remote is brand new/empty, or local is up-to-date, this succeeds immediately!
673
+ push_cmd = ["git", "push", safe_url, f"HEAD:{branch}"]
674
+ res = subprocess.run(push_cmd, cwd=t_dir, capture_output=True, text=True, env=env, timeout=120)
675
+ if res.returncode == 0:
676
+ return True, "Synchronized successfully."
604
677
 
605
- elif action == "sync":
606
- # 1. Try pushing first. If remote is brand new/empty, or local is up-to-date, this succeeds immediately!
607
- push_cmd = ["git", "push", authed_url, f"HEAD:{branch}"]
608
- res = subprocess.run(push_cmd, cwd=t_dir, capture_output=True, text=True, env=env, timeout=120)
609
- if res.returncode == 0:
610
- return True, "Synchronized successfully."
611
-
612
- err_check = (res.stderr + res.stdout).lower()
613
- # If rejected because remote has existing commits (non-fast-forward / fetch first)
614
- if "fetch first" in err_check or "rejected" in err_check or "non-fast-forward" in err_check:
615
- # 2. Pull remote commits with rebase
616
- pull_cmd = ["git"] + user_args + ["pull", "--rebase", "--autostash", authed_url, branch]
617
- pull_res = subprocess.run(pull_cmd, cwd=t_dir, capture_output=True, text=True, env=env, timeout=120)
618
- if pull_res.returncode != 0:
619
- subprocess.run(["git", "rebase", "--abort"], cwd=t_dir, capture_output=True, env=env)
620
- # Fallback for unrelated histories (e.g. GitHub repo created with an initial README)
621
- pull_merge_cmd = ["git"] + user_args + [
622
- "pull", "--no-rebase", "--allow-unrelated-histories", "-X", "theirs", authed_url, branch
623
- ]
624
- pull_res = subprocess.run(pull_merge_cmd, cwd=t_dir, capture_output=True, text=True, env=env, timeout=120)
678
+ err_check = (res.stderr + res.stdout).lower()
679
+ if any(ind in err_check for ind in ["fetch first", "rejected", "non-fast-forward", "behind"]):
680
+ # 2. Pull remote commits with rebase
681
+ pull_cmd = ["git"] + user_args + ["pull", "--rebase", "--autostash", safe_url, branch]
682
+ pull_res = subprocess.run(pull_cmd, cwd=t_dir, capture_output=True, text=True, env=env, timeout=120)
625
683
  if pull_res.returncode != 0:
626
- return False, _sanitize(pull_res.stderr) or _sanitize(pull_res.stdout) or "Git pull failed."
684
+ subprocess.run(["git", "rebase", "--abort"], cwd=t_dir, capture_output=True, env=env)
685
+ return False, "CONFLICT"
627
686
 
628
- # 3. Re-push now that remote is integrated
629
- final_push = subprocess.run(push_cmd, cwd=t_dir, capture_output=True, text=True, env=env, timeout=120)
630
- if final_push.returncode == 0:
631
- return True, "Synchronized successfully."
632
- return False, _sanitize(final_push.stderr) or _sanitize(final_push.stdout) or f"Git push failed with code {final_push.returncode}"
687
+ # 3. Re-push now that remote is integrated
688
+ final_push = subprocess.run(push_cmd, cwd=t_dir, capture_output=True, text=True, env=env, timeout=120)
689
+ if final_push.returncode == 0:
690
+ return True, "Synchronized successfully."
691
+
692
+ final_err = (final_push.stderr + final_push.stdout).lower()
693
+ if any(ind in final_err for ind in ["fetch first", "rejected", "non-fast-forward", "behind"]):
694
+ return False, "CONFLICT"
695
+
696
+ return False, final_push.stderr.strip() or final_push.stdout.strip() or f"Git push failed with code {final_push.returncode}"
633
697
 
634
- # Any other error (e.g. authentication failed, bad URL)
635
- return False, _sanitize(res.stderr) or _sanitize(res.stdout) or f"Git exited with code {res.returncode}"
698
+ return False, res.stderr.strip() or res.stdout.strip() or f"Git exited with code {res.returncode}"
636
699
 
637
- else:
638
- cmd = ["git", "push", authed_url, f"HEAD:{branch}"]
639
- res = subprocess.run(cmd, cwd=t_dir, capture_output=True, text=True, env=env, timeout=120)
640
- if res.returncode == 0:
641
- return True, "Synchronized successfully."
642
- return False, _sanitize(res.stderr) or _sanitize(res.stdout) or f"Git exited with code {res.returncode}"
700
+ else:
701
+ return False, f"Unknown action: {action}"
643
702
 
644
703
  except subprocess.TimeoutExpired:
645
704
  return False, "Connection timed out after 120s."