Skip to content

Commit 5e826a9

Browse files
author
Douglas Lassance
authored
Introduce a functional claim (#20)
1 parent 82a890b commit 5e826a9

File tree

9 files changed

+333
-178
lines changed

9 files changed

+333
-178
lines changed

README.md

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -59,19 +59,24 @@ git -C project add local.png
5959
git -C project commit -m "Add local.png"
6060

6161
# Updating tracked commits with current local changes.
62-
# Because we specified `track_uncommitted`. Uncommitted changes will be stored as sha-less commit.
63-
# Update permissions of all files based on track commits.
64-
# Because `modify_permssions` was passed this will update all permissions of tracked files.
65-
# Permission updates currently comes at high performance cost and is not recommended.
62+
# Because we passed `--track-uncommitted`, uncommitted changes will be stored as sha-less commit.
63+
# Because we passed `--modify-permssions` the file permissions will be updated.
64+
# When passing `--update-hooks`, the update will happen automatically on the following hooks:
65+
# applypatch, post-checkout, post-commit, post-rewrite.
6666
gitalong -C project update
6767

6868
# Checking the status for the files we created.
6969
# Each status will show <spread> <filename> <commit> <local-branches> <remote-branches> <host> <author>.
70-
# Spread flags represent where the commit live.
71-
# It will be displayed in the following order:
70+
# Spread flags represent where the commit lives. It will be displayed in the following order:
7271
# <mine-uncommitted><mine-active-branch><mine-other-branch><remote-matching-branch><remote-other-branch><other-other-branch><other-matching-branch><other-uncomitted>
7372
# A `+` sign means is true, while a `-` sign means false or unknown.
74-
gitalong -C project untracked.txt status uncommited.png local.png current.jpg remote.jpg
73+
gitalong -C project status untracked.txt uncommited.png local.png current.jpg remote.jpg
74+
75+
# Claiming the files to modify them.
76+
# If the file cannot be claimed the "blocking" commit will be returned.
77+
# Since we passed `--modify-permissions`, the claimed file will be made writeable.
78+
# These claimed files will be released automatically on the next update if not modified.
79+
gitalong -C project claim untracked.txt uncommited.png local.png current.jpg remote.jpg
7580
```
7681

7782
### Python

gitalong/batch.py

Lines changed: 156 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
# import datetime
66

77
# from turtle import write
8-
from typing import List, Coroutine
8+
from typing import List, Coroutine, Optional
99

1010
# from unittest import result
1111

@@ -49,6 +49,70 @@ async def _run_command(args: List[str], safe: bool = False) -> str:
4949
return stdout.decode().strip()
5050

5151

52+
async def get_local_only_commits(
53+
repository: Repository, claims: Optional[List[str]] = None
54+
) -> list:
55+
"""
56+
Returns:
57+
list:
58+
Commits that are not on remote branches. Includes a commit that
59+
represents uncommitted changes.
60+
claims (list, optional):
61+
List of files that we want to claim.
62+
They'll be store as uncommitted changes.
63+
"""
64+
local_commits = []
65+
for branch in repository.branches:
66+
await accumulate_local_only_commits(repository, branch.commit, local_commits)
67+
if repository.config.get("track_uncommitted"):
68+
uncommitted_changes_commit = repository.get_uncommitted_changes_commit(
69+
claims=claims
70+
)
71+
72+
# Adding file we want to claim to the uncommitted changes commit.
73+
for claim in claims or []:
74+
claim = repository.get_absolute_path(claim)
75+
if os.path.isfile(claim):
76+
uncommitted_changes_commit.setdefault("changes", []).append(
77+
repository.get_relative_path(claim).replace("\\", "/")
78+
)
79+
80+
if uncommitted_changes_commit:
81+
local_commits.insert(0, uncommitted_changes_commit)
82+
local_commits.sort(key=lambda commit: commit.get("date"), reverse=True)
83+
return local_commits
84+
85+
86+
async def accumulate_local_only_commits(
87+
repository, start: git.Commit, local_commits: List[dict]
88+
):
89+
"""Accumulates a list of local only commit starting from the provided commit.
90+
91+
Args:
92+
local_commits (list): The accumulated local commits.
93+
start (git.objects.Commit):
94+
The commit that we start peeling from last commit.
95+
"""
96+
if repository.is_remote_commit(start.hexsha):
97+
return
98+
99+
commit = Commit(repository)
100+
commit.update_with_sha(start.hexsha)
101+
commit.update_context()
102+
103+
changes = await get_commits_changes([commit])
104+
commit["changes"] = changes[0]
105+
106+
branches_list = await get_commits_branches([commit])
107+
branches = branches_list[0] if branches_list else []
108+
commit["branches"] = {"local": branches}
109+
110+
if commit not in local_commits:
111+
local_commits.append(commit)
112+
for parent in start.parents:
113+
await accumulate_local_only_commits(repository, parent, local_commits)
114+
115+
52116
async def get_files_last_commits( # pylint: disable=too-many-branches,too-many-locals,too-many-statements
53117
filenames: List[str], prune: bool = True
54118
) -> List[Commit]:
@@ -271,21 +335,14 @@ async def update_files_permissions(filenames: List[str]):
271335
write_permissions = []
272336
for filename, last_commit in zip(filenames, last_commits):
273337
repository = Repository.from_filename(os.path.dirname(filename))
274-
spread = last_commit.commit_spread if repository else 0
338+
if not repository:
339+
continue
340+
spread = last_commit.commit_spread
275341
if not spread:
276342
continue
277-
is_uncommitted = (
278-
spread & CommitSpread.MINE_UNCOMMITTED == CommitSpread.MINE_UNCOMMITTED
279-
)
280-
is_local = (
281-
spread & CommitSpread.MINE_ACTIVE_BRANCH == CommitSpread.MINE_ACTIVE_BRANCH
282-
)
283-
is_current = (
284-
spread
285-
& (CommitSpread.MINE_ACTIVE_BRANCH | CommitSpread.REMOTE_MATCHING_BRANCH)
286-
== CommitSpread.MINE_ACTIVE_BRANCH | CommitSpread.REMOTE_MATCHING_BRANCH
287-
)
288-
write_permission = is_uncommitted or is_local or is_current
343+
is_uncommitted = spread == CommitSpread.MINE_UNCOMMITTED
344+
is_local = spread == CommitSpread.MINE_ACTIVE_BRANCH
345+
write_permission = is_uncommitted or is_local
289346
write_permissions.append(write_permission)
290347
tasks.append(_set_write_permission(filename, write_permission))
291348
await asyncio.gather(*tasks)
@@ -294,8 +351,7 @@ async def update_files_permissions(filenames: List[str]):
294351
async def _set_write_permission(
295352
filename: str, write_permission: bool, safe: bool = False
296353
) -> bool:
297-
"""
298-
Set the write permission of a file asynchronously.
354+
"""Set the write permission of a file asynchronously.
299355
300356
Args:
301357
filename (str): The path to the file.
@@ -324,3 +380,87 @@ async def _set_write_permission(
324380
return True
325381
raise
326382
return True
383+
384+
385+
async def get_updated_tracked_commits(
386+
repository: Repository, claims: Optional[List[str]] = None
387+
) -> list:
388+
"""
389+
Returns:
390+
list:
391+
Local commits for all clones with local commits and uncommitted changes
392+
from this clone.
393+
"""
394+
tracked_commits = []
395+
for commit in repository.store.commits:
396+
remote = repository.remote.url
397+
is_other_remote = commit.get("remote") != remote
398+
if is_other_remote or not commit.is_issued_commit():
399+
tracked_commits.append(commit)
400+
continue
401+
402+
for commit in await get_local_only_commits(repository, claims=claims):
403+
tracked_commits.append(commit)
404+
return tracked_commits
405+
406+
407+
async def update_tracked_commits(
408+
repository: Repository, claims: Optional[List[str]] = None
409+
):
410+
"""Pulls the tracked commits from the store and updates them."""
411+
repository.store.commits = await get_updated_tracked_commits(
412+
repository, claims=claims
413+
)
414+
absolute_filenames = []
415+
if repository.config.get("modify_permissions"):
416+
print("Updating permissions")
417+
for filename in repository.files:
418+
absolute_filenames.append(repository.get_absolute_path(filename))
419+
await repository.batch.update_files_permissions(absolute_filenames)
420+
421+
422+
async def claim_files(
423+
filenames: List[str],
424+
prune: bool = True,
425+
) -> List[Commit]:
426+
"""If the file is available for changes, temporarily communicates files as changed.
427+
By communicate we mean the file will be marked as a local change until the next
428+
update of the tracked commits. Also makes the files writable if the configured is
429+
set to affect permissions.
430+
431+
Args:
432+
filename (str):
433+
A list of absolute filenames to claim.
434+
prune (bool, optional): Prune branches if a fetch is necessary.
435+
436+
Returns:
437+
List[Commit]: The blocking commits for the file we want to claim.
438+
"""
439+
blocking_commits = []
440+
last_commits = await get_files_last_commits(filenames, prune=prune)
441+
for last_commit in last_commits:
442+
spread = last_commit.commit_spread
443+
is_local_commit = (
444+
spread & CommitSpread.MINE_ACTIVE_BRANCH == CommitSpread.MINE_ACTIVE_BRANCH
445+
)
446+
is_uncommitted = (
447+
spread & CommitSpread.MINE_UNCOMMITTED == CommitSpread.MINE_UNCOMMITTED
448+
)
449+
blocking_commits.append(
450+
Commit(None) if is_local_commit or is_uncommitted else last_commit
451+
)
452+
453+
# Collect all repositories and corresponding file updates.
454+
filenames_by_repository = {}
455+
for filename_ in filenames:
456+
repository = Repository.from_filename(filename_)
457+
filenames_by_repository.setdefault(repository, []).append(filename_)
458+
459+
# Updating the tracked commits for each repository affected.
460+
for repository in filenames_by_repository:
461+
if repository:
462+
await update_tracked_commits(
463+
repository,
464+
claims=filenames_by_repository.get(repository, []),
465+
)
466+
return blocking_commits

gitalong/cli.py

Lines changed: 53 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -7,21 +7,21 @@
77
from .__info__ import __version__
88
from .enums import CommitSpread
99
from .repository import Repository
10-
from .batch import get_files_last_commits
10+
from .batch import get_files_last_commits, claim_files, update_tracked_commits
1111

1212

1313
def get_status_string(filename: str, commit: dict, spread: int) -> str:
1414
"""Generate a status string for a file and its commit."""
15-
prop = "+" if spread & CommitSpread.MINE_UNCOMMITTED else "-"
16-
prop += "+" if spread & CommitSpread.MINE_ACTIVE_BRANCH else "-"
17-
prop += "+" if spread & CommitSpread.MINE_OTHER_BRANCH else "-"
18-
prop += "+" if spread & CommitSpread.REMOTE_MATCHING_BRANCH else "-"
19-
prop += "+" if spread & CommitSpread.REMOTE_OTHER_BRANCH else "-"
20-
prop += "+" if spread & CommitSpread.THEIR_OTHER_BRANCH else "-"
21-
prop += "+" if spread & CommitSpread.THEIR_MATCHING_BRANCH else "-"
22-
prop += "+" if spread & CommitSpread.THEIR_UNCOMMITTED else "-"
15+
spread_string = "+" if spread & CommitSpread.MINE_UNCOMMITTED else "-"
16+
spread_string += "+" if spread & CommitSpread.MINE_ACTIVE_BRANCH else "-"
17+
spread_string += "+" if spread & CommitSpread.MINE_OTHER_BRANCH else "-"
18+
spread_string += "+" if spread & CommitSpread.REMOTE_MATCHING_BRANCH else "-"
19+
spread_string += "+" if spread & CommitSpread.REMOTE_OTHER_BRANCH else "-"
20+
spread_string += "+" if spread & CommitSpread.THEIR_OTHER_BRANCH else "-"
21+
spread_string += "+" if spread & CommitSpread.THEIR_MATCHING_BRANCH else "-"
22+
spread_string += "+" if spread & CommitSpread.THEIR_UNCOMMITTED else "-"
2323
splits = [
24-
prop,
24+
spread_string,
2525
filename,
2626
commit.get("sha", "-"),
2727
",".join(commit.get("branches", {}).get("local", ["-"])) or "-",
@@ -70,9 +70,8 @@ def config(ctx, prop): # pylint: disable=missing-function-docstring
7070
def update(ctx):
7171
"""Update tracked commits with local changes."""
7272
repository = Repository.from_filename(ctx.obj.get("REPOSITORY", ""))
73-
if not repository:
74-
return
75-
repository.update_tracked_commits()
73+
if repository:
74+
asyncio.run(update_tracked_commits(repository))
7675

7776

7877
@click.command(
@@ -107,16 +106,47 @@ def status(ctx, filename, profile=False): # pylint: disable=missing-function-do
107106

108107

109108
def run_status(ctx, filename): # pylint: disable=missing-function-docstring
110-
file_status = []
111-
commits = asyncio.run(get_files_last_commits(filename))
112-
for _filename, commit in zip(filename, commits):
113-
repository = Repository.from_filename(ctx.obj.get("REPOSITORY", _filename))
114-
absolute_filename = (
115-
repository.get_absolute_path(_filename) if repository else _filename
109+
absolute_filenames = []
110+
for filename_ in filename:
111+
repository = Repository.from_filename(ctx.obj.get("REPOSITORY", filename_))
112+
absolute_filenames.append(
113+
repository.get_absolute_path(filename_) if repository else filename_
114+
)
115+
file_statuses = []
116+
last_commits = asyncio.run(get_files_last_commits(absolute_filenames))
117+
for filename_, last_commit in zip(filename, last_commits):
118+
file_statuses.append(
119+
get_status_string(filename_, last_commit, last_commit.commit_spread)
120+
)
121+
click.echo("\n".join(file_statuses), err=False)
122+
123+
124+
@click.command(
125+
help=(
126+
"Make provided files writeable if possible. Return error code 1 if one or more "
127+
"files cannot be made writeable."
128+
)
129+
)
130+
@click.argument(
131+
"filename",
132+
nargs=-1,
133+
# help="The path to the file that should be made writable."
134+
)
135+
@click.pass_context
136+
def claim(ctx, filename): # pylint: disable=missing-function-docstring
137+
absolute_filenames = []
138+
for filename_ in filename:
139+
repository = Repository.from_filename(ctx.obj.get("REPOSITORY", filename_))
140+
absolute_filenames.append(
141+
repository.get_absolute_path(filename_) if repository else filename_
142+
)
143+
file_statuses = []
144+
blocking_commits = asyncio.run(claim_files(absolute_filenames))
145+
for filename_, blocking_commit in zip(filename, blocking_commits):
146+
file_statuses.append(
147+
get_status_string(filename_, blocking_commit, blocking_commit.commit_spread)
116148
)
117-
spread = commit.commit_spread if repository else 0
118-
file_status.append(get_status_string(absolute_filename, commit, spread))
119-
click.echo("\n".join(file_status), err=False)
149+
click.echo("\n".join(file_statuses), err=False)
120150

121151

122152
@click.command(help="Setup Gitalong in a repository.")
@@ -259,6 +289,7 @@ def cli(ctx, repository, git_binary): # pylint: disable=missing-function-docstr
259289
cli.add_command(update)
260290
cli.add_command(setup)
261291
cli.add_command(status)
292+
cli.add_command(claim)
262293
cli.add_command(version)
263294

264295

gitalong/functions.py

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,16 +41,21 @@ def is_binary_string(string: str) -> bool:
4141
return bool(string.translate(None, textchars)) # pyright: ignore[reportCallIssue]
4242

4343

44-
def is_writeable(filename: str) -> bool:
44+
def is_writeable(filename: str, safe=True) -> bool:
4545
"""
4646
Args:
4747
filename (str): The absolute filename of the file to check.
4848
4949
Returns:
5050
bool: Whether the file is read only.
5151
"""
52-
stat_ = os.stat(filename)
53-
return bool(stat_.st_mode & stat.S_IWUSR)
52+
try:
53+
stat_ = os.stat(filename)
54+
return bool(stat_.st_mode & stat.S_IWUSR)
55+
except FileNotFoundError:
56+
if safe:
57+
return False
58+
raise
5459

5560

5661
def get_real_path(filename: str) -> str:
@@ -125,3 +130,15 @@ def get_filenames_from_move_string(move_string: str) -> tuple:
125130
rights.append(splits[-1])
126131
pair = {move_string.format(*lefts), move_string.format(*rights)}
127132
return tuple(sorted(pair))
133+
134+
135+
def touch_file(filename: str) -> None:
136+
"""
137+
Args:
138+
filename (str): The file to touch.
139+
"""
140+
if not os.path.exists(os.path.dirname(filename)):
141+
os.makedirs(os.path.dirname(filename))
142+
with open(filename, "a", encoding="utf-8"):
143+
pass
144+
os.utime(filename)

0 commit comments

Comments
 (0)